Compare commits
3 Commits
main
...
feature/ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
880adb315e | ||
|
|
187bd1ccae | ||
|
|
2d4dbe82eb |
318
CLAUDE.md
318
CLAUDE.md
@ -1,302 +1,62 @@
|
|||||||
# 🚨 CRITICAL: @ MENTION HANDLING 🚨
|
# Paper Dynasty Real-Time Game Engine
|
||||||
When ANY file is mentioned with @ syntax, you MUST IMMEDIATELY call Read tool on that file BEFORE responding.
|
|
||||||
You will see automatic loads of any @ mentioned filed, this is NOT ENOUGH, it only loads the file contents.
|
|
||||||
You MUST perform Read tool calls on the files directly, even if they were @ included.
|
|
||||||
This is NOT optional - it loads required CLAUDE.md context. along the file path.
|
|
||||||
See @./.claude/force-claude-reads.md for details.
|
|
||||||
|
|
||||||
---
|
Web-based real-time multiplayer baseball simulation replacing legacy Google Sheets.
|
||||||
|
|
||||||
# Paper Dynasty Real-Time Game Engine - Development Guide
|
## Stack
|
||||||
|
|
||||||
## Project Overview
|
- **Backend**: FastAPI + Socket.io, PostgreSQL, Python 3.11+, Pydantic, SQLAlchemy 2.0
|
||||||
|
- **Frontend**: Vue 3 + Nuxt 3, TypeScript strict, Tailwind CSS, Pinia, Socket.io-client
|
||||||
|
- **Auth**: Discord OAuth via HttpOnly cookies (see `COOKIE_AUTH_IMPLEMENTATION.md`)
|
||||||
|
|
||||||
Web-based real-time multiplayer baseball simulation platform replacing legacy Google Sheets system. Consists of:
|
## Commands
|
||||||
- **Shared Backend**: FastAPI (Python 3.11+) with WebSocket support, PostgreSQL persistence
|
|
||||||
- **Dual Frontends**: Separate Vue 3/Nuxt 3 apps per league (SBA and PD) with shared component library
|
|
||||||
|
|
||||||
**Critical Business Driver**: Legacy Google Sheets being deprecated - this is mission-critical replacement.
|
|
||||||
|
|
||||||
## Architecture Principles
|
|
||||||
|
|
||||||
### Backend Philosophy
|
|
||||||
- **Hybrid State Model**: In-memory game state for performance + PostgreSQL for persistence/recovery
|
|
||||||
- **League-Agnostic Core**: Polymorphic player models, config-driven league variations
|
|
||||||
- **Async-First**: All I/O operations use async/await patterns
|
|
||||||
- **Type Safety**: Pydantic models for validation, SQLAlchemy for ORM
|
|
||||||
|
|
||||||
### Frontend Philosophy
|
|
||||||
- **Mobile-First**: Primary design target is mobile portrait mode
|
|
||||||
- **Real-Time Updates**: WebSocket (Socket.io) for all game state changes
|
|
||||||
- **Shared Components**: Maximize reuse between league frontends
|
|
||||||
- **Type Safety**: TypeScript with strict mode
|
|
||||||
|
|
||||||
## Technology Stack
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- FastAPI + Socket.io (WebSocket)
|
|
||||||
- PostgreSQL 14+ with SQLAlchemy 2.0
|
|
||||||
- Pydantic for data validation
|
|
||||||
- pytest for testing
|
|
||||||
|
|
||||||
### Frontend (Per League)
|
|
||||||
- Vue 3 Composition API + Nuxt 3
|
|
||||||
- TypeScript (strict mode)
|
|
||||||
- Tailwind CSS
|
|
||||||
- Pinia for state management
|
|
||||||
- Socket.io-client
|
|
||||||
- Discord OAuth via HttpOnly cookies (see `COOKIE_AUTH_IMPLEMENTATION.md`)
|
|
||||||
|
|
||||||
## Key Technical Patterns
|
|
||||||
|
|
||||||
### Polymorphic Player Architecture
|
|
||||||
Use factory pattern for league-specific player types:
|
|
||||||
- `BasePlayer` (abstract base)
|
|
||||||
- `SbaPlayer` (simple model)
|
|
||||||
- `PdPlayer` (detailed scouting data)
|
|
||||||
- `Lineup.from_api_data(config, data)` factory method
|
|
||||||
|
|
||||||
### WebSocket Event Flow
|
|
||||||
1. Player action → WebSocket → Backend
|
|
||||||
2. Validate against in-memory state
|
|
||||||
3. Process + resolve outcome
|
|
||||||
4. Update in-memory state
|
|
||||||
5. Async write to PostgreSQL
|
|
||||||
6. Broadcast state update to all clients
|
|
||||||
|
|
||||||
### Game State Recovery
|
|
||||||
On reconnect: Load plays from DB → Replay to rebuild state → Send current state
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
strat-gameplay-webapp/
|
|
||||||
├── backend/ # FastAPI game engine
|
|
||||||
│ ├── app/
|
|
||||||
│ │ ├── core/ # Game engine, dice, state management
|
|
||||||
│ │ ├── config/ # League configs and result charts
|
|
||||||
│ │ ├── websocket/ # Socket.io handlers
|
|
||||||
│ │ ├── models/ # Pydantic + SQLAlchemy models
|
|
||||||
│ │ └── api/ # REST endpoints
|
|
||||||
│ └── tests/
|
|
||||||
├── frontend-sba/ # SBA League Nuxt app
|
|
||||||
├── frontend-pd/ # PD League Nuxt app (disabled)
|
|
||||||
├── docker-compose.yml # Base service configuration
|
|
||||||
├── docker-compose.dev.yml # Development overrides (hot-reload)
|
|
||||||
├── docker-compose.prod.yml # Production overrides (optimized)
|
|
||||||
├── start.sh # Single-command startup script
|
|
||||||
└── scripts/env-switch.sh # Environment profile switcher
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 🚀 Recommended: Native Development (Fast)
|
|
||||||
|
|
||||||
For day-to-day development, use native mode for instant startup and hot-reload:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start everything natively (5-10 seconds, hot-reload enabled)
|
# Native development (recommended)
|
||||||
./dev-native.sh start
|
./dev-native.sh start # Start all services
|
||||||
|
./dev-native.sh logs # View logs
|
||||||
|
./dev-native.sh stop # Stop
|
||||||
|
|
||||||
# View logs
|
# Docker (production builds only — dev mode auth is broken)
|
||||||
./dev-native.sh logs
|
./start.sh prod # Production mode
|
||||||
|
./start.sh stop # Stop all
|
||||||
|
|
||||||
# Stop when done
|
# Environment switching
|
||||||
./dev-native.sh stop
|
./scripts/env-switch.sh dev|prod # Copy .env.{profile} → .env
|
||||||
```
|
```
|
||||||
|
|
||||||
**See [NATIVE_DEV_SETUP.md](NATIVE_DEV_SETUP.md) for full setup instructions.**
|
## Architecture
|
||||||
|
|
||||||
### 🐳 Alternative: Docker Workflow
|
### Hybrid State Model
|
||||||
|
- In-memory game state for performance + PostgreSQL for persistence/recovery
|
||||||
|
- On reconnect: Load plays from DB → replay to rebuild state → send current state
|
||||||
|
|
||||||
For testing production builds or Docker-specific scenarios:
|
### Polymorphic Players
|
||||||
|
- `BasePlayer` (abstract) → `SbaPlayer` (simple) / `PdPlayer` (detailed scouting)
|
||||||
|
- Factory: `Lineup.from_api_data(config, data)`
|
||||||
|
|
||||||
```bash
|
### WebSocket Flow
|
||||||
# Production (optimized build)
|
Player action → WebSocket → validate against in-memory state → process → async DB write → broadcast to clients
|
||||||
./start.sh prod
|
|
||||||
|
|
||||||
# Development - DO NOT USE (auth broken)
|
## Code Quality
|
||||||
# ./start.sh dev
|
- Python: Dataclasses preferred, rotating loggers with `f'{__name__}.<className>'`
|
||||||
|
- Error handling: "Raise or Return" pattern — no Optional unless required
|
||||||
|
- Git commits: Prefix with "CLAUDE: "
|
||||||
|
- Security: Zero client trust, server-side logic only, cryptographically secure dice
|
||||||
|
|
||||||
# Stop all services
|
## Key URLs
|
||||||
./start.sh stop
|
|
||||||
|
|
||||||
# View logs
|
| Service | Dev | Prod |
|
||||||
./start.sh logs
|
|---------|-----|------|
|
||||||
|
|
||||||
# Check status
|
|
||||||
./start.sh status
|
|
||||||
|
|
||||||
# Force rebuild
|
|
||||||
./start.sh rebuild [dev|prod]
|
|
||||||
```
|
|
||||||
|
|
||||||
### What Each Mode Does
|
|
||||||
|
|
||||||
| Mode | Backend | Frontend | Use Case |
|
|
||||||
|------|---------|----------|----------|
|
|
||||||
| `prod` | Production build | SSR optimized build | **Always use this** - auth works correctly |
|
|
||||||
| `dev` | Hot-reload (uvicorn --reload) | Hot-reload (nuxt dev) | ❌ Auth broken - do not use |
|
|
||||||
|
|
||||||
### Service URLs
|
|
||||||
|
|
||||||
| Service | Dev Mode | Prod Mode |
|
|
||||||
|---------|----------|-----------|
|
|
||||||
| Frontend | http://localhost:3000 | https://gameplay-demo.manticorum.com |
|
| Frontend | http://localhost:3000 | https://gameplay-demo.manticorum.com |
|
||||||
| Backend API | http://localhost:8000 | https://gameplay-demo.manticorum.com/api |
|
| Backend API | http://localhost:8000 | https://gameplay-demo.manticorum.com/api |
|
||||||
| API Docs | http://localhost:8000/docs | https://gameplay-demo.manticorum.com/api/docs |
|
|
||||||
|
|
||||||
### Docker Compose Architecture
|
|
||||||
|
|
||||||
The stack uses layered compose files:
|
|
||||||
|
|
||||||
- **`docker-compose.yml`** - Base services (Redis, Backend, Frontend-SBA)
|
|
||||||
- **`docker-compose.dev.yml`** - Development overrides (volume mounts, hot-reload)
|
|
||||||
- **`docker-compose.prod.yml`** - Production overrides (optimized builds, restart policies)
|
|
||||||
|
|
||||||
The `start.sh` script handles composing these correctly.
|
|
||||||
|
|
||||||
## Environment Configuration
|
|
||||||
|
|
||||||
### Environment Profiles
|
|
||||||
|
|
||||||
| Profile | Backend URL | Frontend URL |
|
|
||||||
|---------|-------------|--------------|
|
|
||||||
| `dev` | `http://localhost:8000` | `http://localhost:3000` |
|
|
||||||
| `prod` | `https://gameplay-demo.manticorum.com` | `https://gameplay-demo.manticorum.com` |
|
|
||||||
|
|
||||||
### Environment Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `backend/.env` | Active backend config (gitignored) |
|
|
||||||
| `backend/.env.dev` | Local development settings |
|
|
||||||
| `backend/.env.prod` | Production settings |
|
|
||||||
| `frontend-sba/.env` | Active frontend config (gitignored) |
|
|
||||||
| `frontend-sba/.env.dev` | Local development settings |
|
|
||||||
| `frontend-sba/.env.prod` | Production settings |
|
|
||||||
|
|
||||||
### Key Environment Variables
|
|
||||||
|
|
||||||
**Backend** (in `backend/.env`):
|
|
||||||
- `DATABASE_URL` - PostgreSQL connection string
|
|
||||||
- `DISCORD_CLIENT_ID/SECRET` - OAuth credentials
|
|
||||||
- `DISCORD_SERVER_REDIRECT_URI` - Server-side OAuth callback
|
|
||||||
- `FRONTEND_URL` - Frontend base URL for redirects
|
|
||||||
- `CORS_ORIGINS` - Allowed origins (JSON array)
|
|
||||||
- `ALLOWED_DISCORD_IDS` - User whitelist (comma-separated, empty = all)
|
|
||||||
|
|
||||||
**Frontend** (in `frontend-*/.env`):
|
|
||||||
- `NUXT_PUBLIC_API_URL` - Backend API URL (public, client-side)
|
|
||||||
- `NUXT_API_URL_INTERNAL` - Backend URL for SSR (Docker internal: `http://backend:8000`)
|
|
||||||
- `NUXT_PUBLIC_WS_URL` - WebSocket URL
|
|
||||||
- `NUXT_PUBLIC_DISCORD_CLIENT_ID` - OAuth client ID (public)
|
|
||||||
- `NUXT_PUBLIC_DISCORD_REDIRECT_URI` - OAuth callback URL
|
|
||||||
|
|
||||||
### Discord OAuth Setup
|
|
||||||
|
|
||||||
Both environments require redirect URIs in [Discord Developer Portal](https://discord.com/developers/applications):
|
|
||||||
|
|
||||||
| Environment | Redirect URI |
|
|
||||||
|-------------|--------------|
|
|
||||||
| dev | `http://localhost:8000/api/auth/discord/callback/server` |
|
|
||||||
| prod | `https://gameplay-demo.manticorum.com/api/auth/discord/callback/server` |
|
|
||||||
|
|
||||||
### Manual Environment Switching
|
|
||||||
|
|
||||||
If you need to switch between dev/prod configs without Docker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/env-switch.sh dev # Copy .env.dev → .env
|
|
||||||
./scripts/env-switch.sh prod # Copy .env.prod → .env
|
|
||||||
```
|
|
||||||
|
|
||||||
The `start.sh` script handles this automatically based on mode.
|
|
||||||
|
|
||||||
## Development Guidelines
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
- **Python**: Dataclasses preferred, rotating loggers with `f'{__name__}.<className>'`
|
|
||||||
- **Error Handling**: "Raise or Return" pattern - no Optional unless required
|
|
||||||
- **Testing**: Run tests freely without asking permission
|
|
||||||
- **Imports**: Always verify imports during code review to prevent NameErrors
|
|
||||||
- **Git Commits**: Prefix with "CLAUDE: "
|
|
||||||
|
|
||||||
### Performance Targets
|
|
||||||
- Action response: < 500ms
|
|
||||||
- WebSocket delivery: < 200ms
|
|
||||||
- DB writes: < 100ms (async)
|
|
||||||
- State recovery: < 2 seconds
|
|
||||||
|
|
||||||
### Security Requirements
|
|
||||||
- Discord OAuth for authentication
|
|
||||||
- Server-side game logic only (zero client trust)
|
|
||||||
- Cryptographically secure dice rolls
|
|
||||||
- All rules enforced server-side
|
|
||||||
|
|
||||||
## Phase 1 MVP Scope (Weeks 1-13)
|
|
||||||
|
|
||||||
**Core Deliverables**:
|
|
||||||
1. Authentication (Discord OAuth)
|
|
||||||
2. Game creation & lobby
|
|
||||||
3. Complete turn-based gameplay with all strategic decisions
|
|
||||||
4. Real-time WebSocket updates
|
|
||||||
5. Game persistence & recovery
|
|
||||||
6. Spectator mode
|
|
||||||
7. Mobile-optimized UI
|
|
||||||
8. AI opponent support
|
|
||||||
|
|
||||||
**Explicitly Out of Scope for MVP**:
|
|
||||||
- Roster management
|
|
||||||
- Marketplace
|
|
||||||
- Tournaments
|
|
||||||
- Advanced analytics
|
|
||||||
|
|
||||||
## Critical References
|
|
||||||
|
|
||||||
- **Full PRD**: `/mnt/NV2/Development/strat-gameplay-webapp/prd-web-scorecard-1.1.md`
|
|
||||||
- **Player Model Architecture**: PRD lines 378-551
|
|
||||||
- **Database Schema**: PRD lines 553-628
|
|
||||||
- **WebSocket Events**: PRD lines 630-669
|
|
||||||
- **League Config System**: PRD lines 780-846
|
|
||||||
|
|
||||||
## League Differences
|
## League Differences
|
||||||
|
|
||||||
### SBA League
|
- **SBA**: Minimal player data (id, name, image), simpler rules
|
||||||
- Minimal player data (id, name, image)
|
- **PD**: Detailed scouting data, complex probability calculations
|
||||||
- Simpler rules configuration
|
|
||||||
|
|
||||||
### PD League
|
## References
|
||||||
- Detailed scouting data on players
|
|
||||||
- Complex probability calculations
|
|
||||||
- Additional analytics requirements
|
|
||||||
|
|
||||||
## Success Metrics
|
- **Full PRD**: `prd-web-scorecard-1.1.md`
|
||||||
|
- **Native dev setup**: `NATIVE_DEV_SETUP.md`
|
||||||
- 90% player migration within 1 month
|
- Subdirectories have their own CLAUDE.md files for implementation details
|
||||||
- 99.5% uptime
|
|
||||||
- < 500ms average action latency
|
|
||||||
- 60%+ mobile usage
|
|
||||||
- Zero data corruption
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current Implementation Status
|
|
||||||
|
|
||||||
**Phase 3E-Final**: ✅ **COMPLETE** (2025-01-10)
|
|
||||||
|
|
||||||
Backend is production-ready for frontend integration:
|
|
||||||
- ✅ All 20 WebSocket event handlers implemented
|
|
||||||
- ✅ Strategic decisions (defensive/offensive)
|
|
||||||
- ✅ Manual outcome workflow (dice rolling + card reading)
|
|
||||||
- ✅ Player substitutions (3 types)
|
|
||||||
- ✅ Box score statistics (materialized views)
|
|
||||||
- ✅ Position ratings integration (PD league)
|
|
||||||
- ✅ Uncapped hit interactive decision tree (SINGLE_UNCAPPED, DOUBLE_UNCAPPED)
|
|
||||||
- ✅ 2481/2481 tests passing (100%)
|
|
||||||
|
|
||||||
**Next Phase**: Vue 3 + Nuxt 3 frontend implementation with Socket.io client
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Note**: Subdirectories have their own CLAUDE.md files with implementation-specific details to minimize context usage.
|
|
||||||
|
|||||||
@ -1,19 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Turn Indicator -->
|
|
||||||
<div
|
|
||||||
class="rounded-xl shadow-lg p-4 text-center"
|
|
||||||
:class="turnIndicatorClasses"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-center gap-3">
|
|
||||||
<span class="text-3xl">{{ turnIcon }}</span>
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-bold">{{ turnTitle }}</h2>
|
|
||||||
<p class="text-sm opacity-90 mt-0.5">{{ turnSubtitle }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Decision Phase Content -->
|
<!-- Decision Phase Content -->
|
||||||
<div v-if="phase !== 'idle'" class="space-y-4">
|
<div v-if="phase !== 'idle'" class="space-y-4">
|
||||||
<!-- Defensive Phase -->
|
<!-- Defensive Phase -->
|
||||||
@ -33,19 +19,8 @@
|
|||||||
:game-id="gameId"
|
:game-id="gameId"
|
||||||
:is-active="isMyTurn"
|
:is-active="isMyTurn"
|
||||||
:current-decision="currentOffensiveDecision"
|
:current-decision="currentOffensiveDecision"
|
||||||
:has-runners-on-base="hasRunnersOnBase"
|
|
||||||
@submit="handleOffensiveSubmit"
|
@submit="handleOffensiveSubmit"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Stolen Base Attempts (if runners on base) -->
|
|
||||||
<StolenBaseInputs
|
|
||||||
v-if="hasRunnersOnBase"
|
|
||||||
:runners="runners"
|
|
||||||
:is-active="isMyTurn"
|
|
||||||
:current-attempts="currentStealAttempts"
|
|
||||||
@submit="handleStealAttemptsSubmit"
|
|
||||||
@cancel="handleStealAttemptsCancel"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Decision History (Collapsible) -->
|
<!-- Decision History (Collapsible) -->
|
||||||
@ -98,7 +73,6 @@
|
|||||||
v-else
|
v-else
|
||||||
class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center"
|
class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center"
|
||||||
>
|
>
|
||||||
<div class="text-6xl mb-4">⚾</div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
Waiting for Play
|
Waiting for Play
|
||||||
</h3>
|
</h3>
|
||||||
@ -114,7 +88,6 @@ import { ref, computed } from 'vue'
|
|||||||
import type { DefensiveDecision, OffensiveDecision } from '~/types/game'
|
import type { DefensiveDecision, OffensiveDecision } from '~/types/game'
|
||||||
import DefensiveSetup from './DefensiveSetup.vue'
|
import DefensiveSetup from './DefensiveSetup.vue'
|
||||||
import OffensiveApproach from './OffensiveApproach.vue'
|
import OffensiveApproach from './OffensiveApproach.vue'
|
||||||
import StolenBaseInputs from './StolenBaseInputs.vue'
|
|
||||||
|
|
||||||
interface DecisionHistoryItem {
|
interface DecisionHistoryItem {
|
||||||
type: 'Defensive' | 'Offensive'
|
type: 'Defensive' | 'Offensive'
|
||||||
@ -134,7 +107,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
currentDefensiveSetup?: DefensiveDecision
|
currentDefensiveSetup?: DefensiveDecision
|
||||||
currentOffensiveDecision?: Omit<OffensiveDecision, 'steal_attempts'>
|
currentOffensiveDecision?: Omit<OffensiveDecision, 'steal_attempts'>
|
||||||
currentStealAttempts?: number[]
|
|
||||||
decisionHistory?: DecisionHistoryItem[]
|
decisionHistory?: DecisionHistoryItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,62 +117,18 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
second: null,
|
second: null,
|
||||||
third: null,
|
third: null,
|
||||||
}),
|
}),
|
||||||
currentStealAttempts: () => [],
|
|
||||||
decisionHistory: () => [],
|
decisionHistory: () => [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
defensiveSubmit: [decision: DefensiveDecision]
|
defensiveSubmit: [decision: DefensiveDecision]
|
||||||
offensiveSubmit: [decision: Omit<OffensiveDecision, 'steal_attempts'>]
|
offensiveSubmit: [decision: Omit<OffensiveDecision, 'steal_attempts'>]
|
||||||
stealAttemptsSubmit: [attempts: number[]]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const historyExpanded = ref(false)
|
const historyExpanded = ref(false)
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const hasRunnersOnBase = computed(() => {
|
|
||||||
return props.runners.first !== null ||
|
|
||||||
props.runners.second !== null ||
|
|
||||||
props.runners.third !== null
|
|
||||||
})
|
|
||||||
|
|
||||||
const turnIndicatorClasses = computed(() => {
|
|
||||||
if (props.isMyTurn) {
|
|
||||||
return 'bg-gradient-to-r from-green-600 to-green-700 text-white'
|
|
||||||
} else {
|
|
||||||
return 'bg-gradient-to-r from-gray-500 to-gray-600 text-white'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const turnIcon = computed(() => {
|
|
||||||
if (props.phase === 'idle') return '⏸️'
|
|
||||||
if (props.isMyTurn) return '✋'
|
|
||||||
return '⏳'
|
|
||||||
})
|
|
||||||
|
|
||||||
const turnTitle = computed(() => {
|
|
||||||
if (props.phase === 'idle') return 'Waiting for Next Play'
|
|
||||||
if (props.isMyTurn) {
|
|
||||||
return props.phase === 'defensive' ? 'Your Defensive Turn' : 'Your Offensive Turn'
|
|
||||||
} else {
|
|
||||||
return 'Opponent\'s Turn'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const turnSubtitle = computed(() => {
|
|
||||||
if (props.phase === 'idle') return 'No decisions needed right now'
|
|
||||||
if (props.isMyTurn) {
|
|
||||||
if (props.phase === 'defensive') {
|
|
||||||
return 'Set your defensive positioning and strategy'
|
|
||||||
} else {
|
|
||||||
return 'Choose your offensive approach and tactics'
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return 'Waiting for opponent to make their decision'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const recentDecisions = computed(() => {
|
const recentDecisions = computed(() => {
|
||||||
return props.decisionHistory.slice(0, 3)
|
return props.decisionHistory.slice(0, 3)
|
||||||
})
|
})
|
||||||
@ -214,11 +142,4 @@ const handleOffensiveSubmit = (decision: Omit<OffensiveDecision, 'steal_attempts
|
|||||||
emit('offensiveSubmit', decision)
|
emit('offensiveSubmit', decision)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStealAttemptsSubmit = (attempts: number[]) => {
|
|
||||||
emit('stealAttemptsSubmit', attempts)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStealAttemptsCancel = () => {
|
|
||||||
// Reset handled by parent component
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,99 +1,106 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
<div
|
||||||
<!-- Header -->
|
class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-4"
|
||||||
<div class="flex items-center justify-between mb-6">
|
:class="isActive ? 'ring-2 ring-green-500/60 shadow-green-500/20' : ''"
|
||||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
>
|
||||||
<span class="text-2xl">🛡️</span>
|
<form class="space-y-3" @submit.prevent="handleSubmit">
|
||||||
Defensive Setup
|
<!-- Header row: icon + title -->
|
||||||
</h3>
|
<div class="flex items-center">
|
||||||
<span
|
<h3 class="text-base font-bold text-gray-900 dark:text-white">
|
||||||
v-if="!isActive"
|
Defense
|
||||||
class="px-3 py-1 text-xs font-semibold rounded-full bg-gray-200 text-gray-600"
|
</h3>
|
||||||
>
|
|
||||||
Opponent's Turn
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form -->
|
|
||||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
|
||||||
<!-- Infield Depth -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
|
||||||
Infield Depth
|
|
||||||
</label>
|
|
||||||
<ButtonGroup
|
|
||||||
v-model="infieldDepth"
|
|
||||||
:options="infieldDepthOptions"
|
|
||||||
:disabled="!isActive"
|
|
||||||
size="md"
|
|
||||||
variant="primary"
|
|
||||||
vertical
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Outfield Depth -->
|
<!-- Infield depth: segmented control (only shows extra options when runner on 3rd) -->
|
||||||
<div>
|
<div class="flex items-center gap-2">
|
||||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 w-14 flex-shrink-0">Infield</span>
|
||||||
Outfield Depth
|
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
|
||||||
</label>
|
<button
|
||||||
<ButtonGroup
|
v-for="option in infieldOptions"
|
||||||
v-model="outfieldDepth"
|
:key="option.value"
|
||||||
:options="outfieldDepthOptions"
|
type="button"
|
||||||
:disabled="!isActive"
|
:disabled="!isActive"
|
||||||
size="md"
|
:class="segmentClasses(option.value === infieldDepth)"
|
||||||
variant="primary"
|
class="flex-1 py-2 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
/>
|
@click="infieldDepth = option.value"
|
||||||
</div>
|
>
|
||||||
|
{{ option.label }}
|
||||||
<!-- Visual Preview -->
|
</button>
|
||||||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-700 dark:to-gray-600 rounded-lg p-4">
|
|
||||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Current Setup
|
|
||||||
</h4>
|
|
||||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium text-gray-600 dark:text-gray-400">Infield:</span>
|
|
||||||
<span class="ml-1 text-gray-900 dark:text-white">{{ infieldDisplay }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="font-medium text-gray-600 dark:text-gray-400">Outfield:</span>
|
|
||||||
<span class="ml-1 text-gray-900 dark:text-white">{{ outfieldDisplay }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2">
|
|
||||||
<span class="font-medium text-gray-600 dark:text-gray-400">Holding:</span>
|
|
||||||
<span class="ml-1 text-gray-900 dark:text-white">{{ holdingDisplay }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Outfield depth: only rendered when shallow option exists (walk-off scenario) -->
|
||||||
<ActionButton
|
<div v-if="showOutfieldRow" class="flex items-center gap-2">
|
||||||
type="submit"
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 w-14 flex-shrink-0">Outfield</span>
|
||||||
variant="success"
|
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
|
||||||
size="lg"
|
<button
|
||||||
:disabled="!isActive"
|
v-for="option in outfieldOptions"
|
||||||
:loading="submitting"
|
:key="option.value"
|
||||||
full-width
|
type="button"
|
||||||
>
|
:disabled="!isActive"
|
||||||
{{ submitButtonText }}
|
:class="segmentClasses(option.value === outfieldDepth)"
|
||||||
</ActionButton>
|
class="flex-1 py-2 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
@click="outfieldDepth = option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hold runners: pill toggles for occupied bases only -->
|
||||||
|
<div v-if="occupiedBases.length > 0" class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 w-14 flex-shrink-0">Hold</span>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="base in occupiedBases"
|
||||||
|
:key="base"
|
||||||
|
type="button"
|
||||||
|
:disabled="!isActive"
|
||||||
|
:class="holdPillClasses(isHeld(base))"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded-full border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
@click="toggleHold(base)"
|
||||||
|
>
|
||||||
|
{{ baseLabel(base) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm button: full-width, prominent (matches Roll Dice style) -->
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="!isActive || submitting"
|
||||||
|
:class="[
|
||||||
|
'px-8 py-4 rounded-lg font-bold text-lg transition-all duration-200 shadow-lg min-h-[60px] min-w-[200px] w-full',
|
||||||
|
isActive && !submitting
|
||||||
|
? 'bg-gradient-to-r from-green-500 to-green-600 text-white hover:from-green-600 hover:to-green-700 hover:shadow-xl active:scale-95'
|
||||||
|
: 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span v-if="submitting" class="flex items-center justify-center gap-2">
|
||||||
|
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" />
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
Confirming...
|
||||||
|
</span>
|
||||||
|
<span v-else>Confirm Defense</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import type { DefensiveDecision, GameState } from '~/types/game'
|
import type { DefensiveDecision } from '~/types/game'
|
||||||
import ButtonGroup from '~/components/UI/ButtonGroup.vue'
|
|
||||||
import type { ButtonGroupOption } from '~/components/UI/ButtonGroup.vue'
|
|
||||||
import ActionButton from '~/components/UI/ActionButton.vue'
|
|
||||||
import { useDefensiveSetup } from '~/composables/useDefensiveSetup'
|
import { useDefensiveSetup } from '~/composables/useDefensiveSetup'
|
||||||
|
import { useGameStore } from '~/store/game'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
gameId: string
|
gameId: string
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
currentSetup?: DefensiveDecision
|
currentSetup?: DefensiveDecision
|
||||||
gameState?: GameState
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@ -104,76 +111,80 @@ const emit = defineEmits<{
|
|||||||
submit: [setup: DefensiveDecision]
|
submit: [setup: DefensiveDecision]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { infieldDepth, outfieldDepth, holdRunnersArray, getDecision, syncFromDecision } = useDefensiveSetup()
|
const gameStore = useGameStore()
|
||||||
|
const { infieldDepth, outfieldDepth, holdRunnersArray, isHeld, toggleHold, getDecision, syncFromDecision } = useDefensiveSetup()
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
|
||||||
// Dynamic options based on game state
|
// Read game state from store instead of prop (fixes bug where gameState was never passed)
|
||||||
const infieldDepthOptions = computed<ButtonGroupOption[]>(() => {
|
const storeGameState = computed(() => gameStore.gameState)
|
||||||
const options: ButtonGroupOption[] = [
|
|
||||||
{ value: 'normal', label: 'Normal', icon: '•' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Only show infield_in and corners_in if runner on third
|
// Determine which bases are occupied
|
||||||
if (props.gameState?.on_third) {
|
const occupiedBases = computed<number[]>(() => {
|
||||||
options.push({ value: 'infield_in', label: 'Infield In', icon: '⬆️' })
|
const gs = storeGameState.value
|
||||||
options.push({ value: 'corners_in', label: 'Corners In', icon: '◀️▶️' })
|
if (!gs) return []
|
||||||
|
const bases: number[] = []
|
||||||
|
if (gs.on_first) bases.push(1)
|
||||||
|
if (gs.on_second) bases.push(2)
|
||||||
|
if (gs.on_third) bases.push(3)
|
||||||
|
return bases
|
||||||
|
})
|
||||||
|
|
||||||
|
// Infield options: always show Normal; add IF In + Corners when runner on 3rd
|
||||||
|
const infieldOptions = computed(() => {
|
||||||
|
const options = [{ value: 'normal' as const, label: 'Normal' }]
|
||||||
|
if (storeGameState.value?.on_third) {
|
||||||
|
options.push({ value: 'infield_in' as const, label: 'IF In' })
|
||||||
|
options.push({ value: 'corners_in' as const, label: 'Corners' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return options
|
return options
|
||||||
})
|
})
|
||||||
|
|
||||||
const outfieldDepthOptions = computed<ButtonGroupOption[]>(() => {
|
// Outfield options: only show row when shallow is available (walk-off scenario)
|
||||||
const options: ButtonGroupOption[] = [
|
const isWalkOffScenario = computed(() => {
|
||||||
{ value: 'normal', label: 'Normal', icon: '•' },
|
const gs = storeGameState.value
|
||||||
]
|
if (!gs) return false
|
||||||
|
const isHomeBatting = gs.half === 'bottom'
|
||||||
|
const isLateInning = gs.inning >= 9
|
||||||
|
const isCloseGame = isHomeBatting
|
||||||
|
? gs.home_score <= gs.away_score
|
||||||
|
: gs.away_score <= gs.home_score
|
||||||
|
const hasRunners = gs.on_first || gs.on_second || gs.on_third
|
||||||
|
return isHomeBatting && isLateInning && isCloseGame && !!hasRunners
|
||||||
|
})
|
||||||
|
|
||||||
// Check for walk-off scenario
|
const showOutfieldRow = computed(() => isWalkOffScenario.value)
|
||||||
if (props.gameState) {
|
|
||||||
const { inning, half, home_score, away_score, on_first, on_second, on_third } = props.gameState
|
|
||||||
const isHomeBatting = half === 'bottom'
|
|
||||||
const isLateInning = inning >= 9
|
|
||||||
const isCloseGame = isHomeBatting
|
|
||||||
? home_score <= away_score
|
|
||||||
: away_score <= home_score
|
|
||||||
const hasRunners = on_first || on_second || on_third
|
|
||||||
|
|
||||||
// Only show shallow in walk-off situations
|
const outfieldOptions = computed(() => {
|
||||||
if (isHomeBatting && isLateInning && isCloseGame && hasRunners) {
|
const options = [{ value: 'normal' as const, label: 'Normal' }]
|
||||||
options.push({ value: 'shallow', label: 'Shallow', icon: '⬇️' })
|
if (isWalkOffScenario.value) {
|
||||||
}
|
options.push({ value: 'shallow' as const, label: 'Shallow' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return options
|
return options
|
||||||
})
|
})
|
||||||
|
|
||||||
// Display helpers
|
// Style helpers
|
||||||
const infieldDisplay = computed(() => {
|
const segmentClasses = (selected: boolean) => {
|
||||||
const option = infieldDepthOptions.value.find(opt => opt.value === infieldDepth.value)
|
if (selected) {
|
||||||
return option?.label || 'Normal'
|
return 'bg-gradient-to-r from-primary to-blue-600 text-white border-r border-blue-600 last:border-r-0'
|
||||||
})
|
}
|
||||||
|
return 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 border-r border-gray-300 dark:border-gray-600 last:border-r-0'
|
||||||
|
}
|
||||||
|
|
||||||
const outfieldDisplay = computed(() => {
|
const holdPillClasses = (held: boolean) => {
|
||||||
const option = outfieldDepthOptions.value.find(opt => opt.value === outfieldDepth.value)
|
if (held) {
|
||||||
return option?.label || 'Normal'
|
return 'border-blue-500 bg-gradient-to-r from-primary to-blue-600 text-white'
|
||||||
})
|
}
|
||||||
|
return 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||||
|
}
|
||||||
|
|
||||||
const holdingDisplay = computed(() => {
|
const baseLabel = (base: number) => {
|
||||||
const arr = holdRunnersArray.value
|
if (base === 1) return '1B'
|
||||||
if (arr.length === 0) return 'None'
|
if (base === 2) return '2B'
|
||||||
return arr.map(base => {
|
if (base === 3) return '3B'
|
||||||
if (base === 1) return '1st'
|
return `${base}B`
|
||||||
if (base === 2) return '2nd'
|
}
|
||||||
if (base === 3) return '3rd'
|
|
||||||
return base
|
|
||||||
}).join(', ')
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitButtonText = computed(() => {
|
|
||||||
if (!props.isActive) return 'Wait for Your Turn'
|
|
||||||
return 'Submit Defensive Setup'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
|||||||
@ -2,8 +2,7 @@
|
|||||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
<span class="text-2xl">⚔️</span>
|
|
||||||
Offensive Action
|
Offensive Action
|
||||||
</h3>
|
</h3>
|
||||||
<span
|
<span
|
||||||
@ -26,18 +25,16 @@
|
|||||||
v-for="option in availableActions"
|
v-for="option in availableActions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="!isActive || option.disabled"
|
:disabled="!isActive"
|
||||||
:class="getActionButtonClasses(option.value, option.disabled)"
|
:class="getActionButtonClasses(option.value)"
|
||||||
class="touch-manipulation"
|
class="touch-manipulation"
|
||||||
:title="option.disabledReason"
|
|
||||||
@click="selectAction(option.value)"
|
@click="selectAction(option.value)"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="text-2xl flex-shrink-0">{{ option.icon }}</span>
|
|
||||||
<div class="flex-1 text-left">
|
<div class="flex-1 text-left">
|
||||||
<div class="font-semibold text-base">{{ option.label }}</div>
|
<div class="font-semibold text-base">{{ option.label }}</div>
|
||||||
<div class="text-sm opacity-90 mt-0.5">
|
<div class="text-sm opacity-90 mt-0.5">
|
||||||
{{ option.disabled ? option.disabledReason : option.description }}
|
{{ option.description }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="localDecision.action === option.value" class="flex-shrink-0">
|
<div v-if="localDecision.action === option.value" class="flex-shrink-0">
|
||||||
@ -48,22 +45,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Current Strategy Summary -->
|
|
||||||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-700 dark:to-gray-600 rounded-lg p-4">
|
|
||||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Current Strategy
|
|
||||||
</h4>
|
|
||||||
<div class="space-y-1 text-xs">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium text-gray-600 dark:text-gray-400">Action:</span>
|
|
||||||
<span class="ml-1 text-gray-900 dark:text-white">{{ currentActionLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="actionRequiresSpecialHandling" class="mt-2 p-2 bg-yellow-100 dark:bg-yellow-900/30 rounded">
|
|
||||||
<span class="font-medium text-yellow-800 dark:text-yellow-200">{{ specialHandlingNote }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<ActionButton
|
<ActionButton
|
||||||
type="submit"
|
type="submit"
|
||||||
@ -83,126 +64,99 @@
|
|||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import type { OffensiveDecision } from '~/types/game'
|
import type { OffensiveDecision } from '~/types/game'
|
||||||
import ActionButton from '~/components/UI/ActionButton.vue'
|
import ActionButton from '~/components/UI/ActionButton.vue'
|
||||||
|
import { useGameStore } from '~/store/game'
|
||||||
|
|
||||||
interface ActionOption {
|
interface ActionOption {
|
||||||
value: OffensiveDecision['action']
|
value: OffensiveDecision['action']
|
||||||
label: string
|
label: string
|
||||||
icon: string
|
|
||||||
description: string
|
description: string
|
||||||
disabled: boolean
|
|
||||||
disabledReason?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
gameId: string
|
gameId: string
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
currentDecision?: Omit<OffensiveDecision, 'steal_attempts'>
|
currentDecision?: Omit<OffensiveDecision, 'steal_attempts'>
|
||||||
hasRunnersOnBase?: boolean
|
|
||||||
runnerOnFirst?: boolean
|
|
||||||
runnerOnSecond?: boolean
|
|
||||||
runnerOnThird?: boolean
|
|
||||||
basesLoaded?: boolean
|
|
||||||
outs?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
isActive: false,
|
isActive: false,
|
||||||
hasRunnersOnBase: false,
|
|
||||||
runnerOnFirst: false,
|
|
||||||
runnerOnSecond: false,
|
|
||||||
runnerOnThird: false,
|
|
||||||
basesLoaded: false,
|
|
||||||
outs: 0,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
submit: [decision: Omit<OffensiveDecision, 'steal_attempts'>]
|
submit: [decision: Omit<OffensiveDecision, 'steal_attempts'>]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const localDecision = ref<Omit<OffensiveDecision, 'steal_attempts'>>({
|
const localDecision = ref<Omit<OffensiveDecision, 'steal_attempts'>>({
|
||||||
action: props.currentDecision?.action || 'swing_away',
|
action: props.currentDecision?.action || 'swing_away',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Action options with smart filtering
|
// Read game state from store (fixes bug where props were never passed from DecisionPanel)
|
||||||
const availableActions = computed<ActionOption[]>(() => {
|
const storeGameState = computed(() => gameStore.gameState)
|
||||||
const twoOuts = props.outs >= 2
|
|
||||||
|
|
||||||
return [
|
const hasRunnersOnBase = computed(() => {
|
||||||
|
const gs = storeGameState.value
|
||||||
|
if (!gs) return false
|
||||||
|
return !!(gs.on_first || gs.on_second || gs.on_third)
|
||||||
|
})
|
||||||
|
|
||||||
|
const runnerOnFirst = computed(() => !!storeGameState.value?.on_first)
|
||||||
|
const runnerOnThird = computed(() => !!storeGameState.value?.on_third)
|
||||||
|
const outs = computed(() => storeGameState.value?.outs ?? 0)
|
||||||
|
|
||||||
|
// Action options: only include actions whose conditions are met (hidden, not disabled)
|
||||||
|
const availableActions = computed<ActionOption[]>(() => {
|
||||||
|
const actions: ActionOption[] = [
|
||||||
{
|
{
|
||||||
value: 'swing_away',
|
value: 'swing_away',
|
||||||
label: 'Swing Away',
|
label: 'Swing Away',
|
||||||
icon: '⚾',
|
|
||||||
description: 'Normal swing, no special tactics',
|
description: 'Normal swing, no special tactics',
|
||||||
disabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'steal',
|
|
||||||
label: 'Steal',
|
|
||||||
icon: '🏃',
|
|
||||||
description: 'Attempt to steal base(s) - configure on steal inputs tab',
|
|
||||||
disabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'check_jump',
|
|
||||||
label: 'Check Jump',
|
|
||||||
icon: '👀',
|
|
||||||
description: 'Lead runner checks jump at start of delivery',
|
|
||||||
disabled: !props.hasRunnersOnBase,
|
|
||||||
disabledReason: 'Requires runner on base',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'hit_and_run',
|
|
||||||
label: 'Hit and Run',
|
|
||||||
icon: '💨',
|
|
||||||
description: 'Runner(s) take off as pitcher delivers; batter must make contact',
|
|
||||||
disabled: !props.hasRunnersOnBase,
|
|
||||||
disabledReason: 'Requires runner on base',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'sac_bunt',
|
|
||||||
label: 'Sacrifice Bunt',
|
|
||||||
icon: '🎯',
|
|
||||||
description: 'Bunt to advance runners, batter likely out',
|
|
||||||
disabled: twoOuts,
|
|
||||||
disabledReason: twoOuts ? 'Cannot bunt with 2 outs' : undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'squeeze_bunt',
|
|
||||||
label: 'Squeeze Bunt',
|
|
||||||
icon: '🔥',
|
|
||||||
description: 'Runner on 3rd breaks for home as pitcher delivers',
|
|
||||||
disabled: !props.runnerOnThird || twoOuts,
|
|
||||||
disabledReason: twoOuts
|
|
||||||
? 'Cannot squeeze with 2 outs'
|
|
||||||
: !props.runnerOnThird
|
|
||||||
? 'Requires runner on third'
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Check jump: requires any runner on base
|
||||||
|
if (hasRunnersOnBase.value) {
|
||||||
|
actions.push({
|
||||||
|
value: 'check_jump',
|
||||||
|
label: 'Check Jump',
|
||||||
|
description: 'Lead runner checks jump at start of delivery',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hit and run: requires runner on first and/or third (NOT second only)
|
||||||
|
if (runnerOnFirst.value || runnerOnThird.value) {
|
||||||
|
actions.push({
|
||||||
|
value: 'hit_and_run',
|
||||||
|
label: 'Hit and Run',
|
||||||
|
description: 'Runner(s) take off as pitcher delivers; batter must make contact',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sac bunt: requires < 2 outs AND runners on base
|
||||||
|
if (outs.value < 2 && hasRunnersOnBase.value) {
|
||||||
|
actions.push({
|
||||||
|
value: 'sac_bunt',
|
||||||
|
label: 'Sacrifice Bunt',
|
||||||
|
description: 'Bunt to advance runners, batter likely out',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Squeeze bunt: requires < 2 outs AND runner on third
|
||||||
|
if (outs.value < 2 && runnerOnThird.value) {
|
||||||
|
actions.push({
|
||||||
|
value: 'squeeze_bunt',
|
||||||
|
label: 'Squeeze Bunt',
|
||||||
|
description: 'Runner on 3rd breaks for home as pitcher delivers',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions
|
||||||
})
|
})
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const currentActionLabel = computed(() => {
|
|
||||||
const option = availableActions.value.find(opt => opt.value === localDecision.value.action)
|
|
||||||
return option?.label || 'Swing Away'
|
|
||||||
})
|
|
||||||
|
|
||||||
const actionRequiresSpecialHandling = computed(() => {
|
|
||||||
return localDecision.value.action === 'steal' || localDecision.value.action === 'squeeze_bunt'
|
|
||||||
})
|
|
||||||
|
|
||||||
const specialHandlingNote = computed(() => {
|
|
||||||
if (localDecision.value.action === 'steal') {
|
|
||||||
return 'Configure which bases to steal on the Stolen Base Inputs tab'
|
|
||||||
}
|
|
||||||
if (localDecision.value.action === 'squeeze_bunt') {
|
|
||||||
return 'R3 will break for home as pitcher delivers - high risk, high reward!'
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasChanges = computed(() => {
|
const hasChanges = computed(() => {
|
||||||
if (!props.currentDecision) return true
|
if (!props.currentDecision) return true
|
||||||
return localDecision.value.action !== props.currentDecision.action
|
return localDecision.value.action !== props.currentDecision.action
|
||||||
@ -217,22 +171,13 @@ const submitButtonText = computed(() => {
|
|||||||
// Methods
|
// Methods
|
||||||
const selectAction = (action: OffensiveDecision['action']) => {
|
const selectAction = (action: OffensiveDecision['action']) => {
|
||||||
if (!props.isActive) return
|
if (!props.isActive) return
|
||||||
|
|
||||||
// Check if action is disabled
|
|
||||||
const option = availableActions.value.find(opt => opt.value === action)
|
|
||||||
if (option?.disabled) return
|
|
||||||
|
|
||||||
localDecision.value.action = action
|
localDecision.value.action = action
|
||||||
}
|
}
|
||||||
|
|
||||||
const getActionButtonClasses = (action: OffensiveDecision['action'], disabled: boolean) => {
|
const getActionButtonClasses = (action: OffensiveDecision['action']) => {
|
||||||
const isSelected = localDecision.value.action === action
|
const isSelected = localDecision.value.action === action
|
||||||
const base = 'w-full p-4 rounded-lg border-2 transition-all duration-200 disabled:cursor-not-allowed'
|
const base = 'w-full p-4 rounded-lg border-2 transition-all duration-200 disabled:cursor-not-allowed'
|
||||||
|
|
||||||
if (disabled) {
|
|
||||||
return `${base} bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border-gray-200 dark:border-gray-700 opacity-60`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
return `${base} bg-gradient-to-r from-blue-600 to-blue-700 text-white border-blue-700 shadow-lg`
|
return `${base} bg-gradient-to-r from-blue-600 to-blue-700 text-white border-blue-700 shadow-lg`
|
||||||
} else {
|
} else {
|
||||||
@ -258,12 +203,12 @@ watch(() => props.currentDecision, (newDecision) => {
|
|||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
// Auto-reset to swing_away if current action becomes invalid
|
// Auto-reset to swing_away if current action is no longer available
|
||||||
watch(() => availableActions.value, (actions) => {
|
watch(() => availableActions.value, (actions) => {
|
||||||
const currentAction = localDecision.value.action
|
const currentAction = localDecision.value.action
|
||||||
const currentOption = actions.find(opt => opt.value === currentAction)
|
const stillAvailable = actions.some(opt => opt.value === currentAction)
|
||||||
|
|
||||||
if (currentOption?.disabled) {
|
if (!stillAvailable) {
|
||||||
localDecision.value.action = 'swing_away'
|
localDecision.value.action = 'swing_away'
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|||||||
@ -26,9 +26,8 @@
|
|||||||
<div class="text-[10px] text-gray-500">{{ base }}</div>
|
<div class="text-[10px] text-gray-500">{{ base }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hold runner icon -->
|
<!-- Hold runner status pill (always visible for occupied bases, interactive only during defensive phase) -->
|
||||||
<button
|
<button
|
||||||
v-if="holdInteractive || isHeld"
|
|
||||||
type="button"
|
type="button"
|
||||||
:class="[
|
:class="[
|
||||||
'hold-icon flex-shrink-0 w-10 rounded-lg flex items-center justify-center transition-all duration-200 px-1 self-stretch gap-0.5',
|
'hold-icon flex-shrink-0 w-10 rounded-lg flex items-center justify-center transition-all duration-200 px-1 self-stretch gap-0.5',
|
||||||
|
|||||||
@ -1,157 +1,279 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { mount } from "@vue/test-utils";
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { createPinia, setActivePinia } from "pinia";
|
||||||
import DefensiveSetup from "~/components/Decisions/DefensiveSetup.vue";
|
import DefensiveSetup from "~/components/Decisions/DefensiveSetup.vue";
|
||||||
import { useDefensiveSetup } from "~/composables/useDefensiveSetup";
|
import { useDefensiveSetup } from "~/composables/useDefensiveSetup";
|
||||||
import type { DefensiveDecision } from "~/types/game";
|
import { useGameStore } from "~/store/game";
|
||||||
|
import type { DefensiveDecision, GameState } from "~/types/game";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a minimal GameState for testing, with sensible defaults.
|
||||||
|
* Only override the fields you care about per test.
|
||||||
|
*/
|
||||||
|
function makeGameState(overrides: Partial<GameState> = {}): GameState {
|
||||||
|
return {
|
||||||
|
game_id: "test-game-123",
|
||||||
|
league_id: "sba",
|
||||||
|
home_team_id: 1,
|
||||||
|
away_team_id: 2,
|
||||||
|
home_team_is_ai: false,
|
||||||
|
away_team_is_ai: false,
|
||||||
|
creator_discord_id: null,
|
||||||
|
auto_mode: false,
|
||||||
|
status: "active",
|
||||||
|
inning: 5,
|
||||||
|
half: "top",
|
||||||
|
outs: 0,
|
||||||
|
balls: 0,
|
||||||
|
strikes: 0,
|
||||||
|
home_score: 3,
|
||||||
|
away_score: 2,
|
||||||
|
on_first: null,
|
||||||
|
on_second: null,
|
||||||
|
on_third: null,
|
||||||
|
current_batter: { lineup_id: 10, card_id: 100, position: "LF", batting_order: 1, is_active: true },
|
||||||
|
current_pitcher: { lineup_id: 20, card_id: 200, position: "P", batting_order: null, is_active: true },
|
||||||
|
current_catcher: null,
|
||||||
|
current_on_base_code: 0,
|
||||||
|
away_team_batter_idx: 0,
|
||||||
|
home_team_batter_idx: 0,
|
||||||
|
pending_decision: null,
|
||||||
|
decision_phase: "awaiting_defensive",
|
||||||
|
decisions_this_play: {},
|
||||||
|
pending_defensive_decision: null,
|
||||||
|
pending_offensive_decision: null,
|
||||||
|
pending_manual_roll: null,
|
||||||
|
pending_x_check: null,
|
||||||
|
pending_uncapped_hit: null,
|
||||||
|
play_count: 0,
|
||||||
|
last_play_result: null,
|
||||||
|
created_at: "2025-01-01T00:00:00Z",
|
||||||
|
started_at: "2025-01-01T00:00:00Z",
|
||||||
|
completed_at: null,
|
||||||
|
...overrides,
|
||||||
|
} as GameState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper to create a mock runner LineupPlayerState */
|
||||||
|
function makeRunner(lineupId: number, position: string = "LF") {
|
||||||
|
return { lineup_id: lineupId, card_id: lineupId * 10, position, batting_order: 1, is_active: true };
|
||||||
|
}
|
||||||
|
|
||||||
describe("DefensiveSetup", () => {
|
describe("DefensiveSetup", () => {
|
||||||
|
let pinia: ReturnType<typeof createPinia>;
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
gameId: "test-game-123",
|
gameId: "test-game-123",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
pinia = createPinia();
|
||||||
|
setActivePinia(pinia);
|
||||||
// Reset the singleton composable state before each test
|
// Reset the singleton composable state before each test
|
||||||
const { reset } = useDefensiveSetup();
|
const { reset } = useDefensiveSetup();
|
||||||
reset();
|
reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: mount with game state injected into the store.
|
||||||
|
* Sets up Pinia and populates gameStore.gameState before mounting.
|
||||||
|
*/
|
||||||
|
function mountWithGameState(gameStateOverrides: Partial<GameState> = {}, propsOverrides: Record<string, any> = {}) {
|
||||||
|
const gameStore = useGameStore();
|
||||||
|
gameStore.setGameState(makeGameState(gameStateOverrides));
|
||||||
|
return mount(DefensiveSetup, {
|
||||||
|
props: { ...defaultProps, ...propsOverrides },
|
||||||
|
global: { plugins: [pinia] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("Rendering", () => {
|
describe("Rendering", () => {
|
||||||
it("renders component with header", () => {
|
it("renders component with compact header", () => {
|
||||||
const wrapper = mount(DefensiveSetup, {
|
/**
|
||||||
props: defaultProps,
|
* The compact layout uses "Defense" title (not the old "Defensive Setup").
|
||||||
});
|
* No emojis in the header.
|
||||||
|
*/
|
||||||
expect(wrapper.text()).toContain("Defensive Setup");
|
const wrapper = mountWithGameState();
|
||||||
|
expect(wrapper.text()).toContain("Defense");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows opponent turn indicator when not active", () => {
|
it("renders prominent full-width confirm button", () => {
|
||||||
const wrapper = mount(DefensiveSetup, {
|
/**
|
||||||
props: {
|
* The confirm button should be a large, full-width green button
|
||||||
...defaultProps,
|
* matching the Roll Dice button style.
|
||||||
isActive: false,
|
*/
|
||||||
},
|
const wrapper = mountWithGameState();
|
||||||
});
|
expect(wrapper.text()).toContain("Confirm Defense");
|
||||||
|
|
||||||
expect(wrapper.text()).toContain("Opponent's Turn");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders all form sections", () => {
|
it("does not render old preview box or ButtonGroup", () => {
|
||||||
const wrapper = mount(DefensiveSetup, {
|
/**
|
||||||
props: defaultProps,
|
* The compact layout removes the "Current Setup" preview box
|
||||||
});
|
* and replaces ButtonGroup with inline segmented buttons.
|
||||||
|
*/
|
||||||
expect(wrapper.text()).toContain("Infield Depth");
|
const wrapper = mountWithGameState();
|
||||||
expect(wrapper.text()).toContain("Outfield Depth");
|
expect(wrapper.text()).not.toContain("Current Setup");
|
||||||
expect(wrapper.text()).toContain("Current Setup");
|
expect(wrapper.text()).not.toContain("Infield Depth");
|
||||||
|
expect(wrapper.text()).not.toContain("Outfield Depth");
|
||||||
|
expect(wrapper.findAllComponents({ name: "ButtonGroup" })).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("Initial Values", () => {
|
it("always shows infield row with Normal option", () => {
|
||||||
it("uses default values when no currentSetup provided", () => {
|
/**
|
||||||
const wrapper = mount(DefensiveSetup, {
|
* Infield segmented control always renders with at least "Normal".
|
||||||
props: defaultProps,
|
*/
|
||||||
});
|
const wrapper = mountWithGameState();
|
||||||
|
expect(wrapper.text()).toContain("Infield");
|
||||||
// Check preview shows defaults
|
|
||||||
expect(wrapper.text()).toContain("Normal");
|
expect(wrapper.text()).toContain("Normal");
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("syncs composable from provided currentSetup via watcher", async () => {
|
describe("Infield Options (runner on 3rd)", () => {
|
||||||
|
it("shows only Normal when no runner on 3rd", () => {
|
||||||
/**
|
/**
|
||||||
* When currentSetup prop is provided, the component should sync the
|
* When there's no runner on 3rd base, infield should only show "Normal".
|
||||||
* composable state to match it. This verifies the prop->composable sync.
|
* IF In and Corners should NOT appear.
|
||||||
*/
|
*/
|
||||||
const currentSetup: DefensiveDecision = {
|
const wrapper = mountWithGameState({ on_third: null });
|
||||||
infield_depth: "normal",
|
expect(wrapper.text()).toContain("Normal");
|
||||||
outfield_depth: "normal",
|
expect(wrapper.text()).not.toContain("IF In");
|
||||||
hold_runners: [1, 3],
|
expect(wrapper.text()).not.toContain("Corners");
|
||||||
};
|
});
|
||||||
|
|
||||||
mount(DefensiveSetup, {
|
it("shows all three infield options when runner on 3rd", () => {
|
||||||
props: {
|
/**
|
||||||
...defaultProps,
|
* When there IS a runner on 3rd, infield should show Normal, IF In, and Corners.
|
||||||
currentSetup,
|
* This is driven by game state from the store (not a prop).
|
||||||
},
|
*/
|
||||||
});
|
const wrapper = mountWithGameState({ on_third: makeRunner(30) });
|
||||||
|
expect(wrapper.text()).toContain("Normal");
|
||||||
|
expect(wrapper.text()).toContain("IF In");
|
||||||
|
expect(wrapper.text()).toContain("Corners");
|
||||||
|
});
|
||||||
|
|
||||||
// The composable should be synced from the prop via the watcher
|
it("can select infield_in when runner on 3rd", async () => {
|
||||||
const { holdRunnersArray, infieldDepth, outfieldDepth } =
|
/**
|
||||||
useDefensiveSetup();
|
* Clicking the IF In button should update the composable's infieldDepth value.
|
||||||
// Watcher fires on prop change, check initial sync happens
|
*/
|
||||||
expect(infieldDepth.value).toBe("normal");
|
const wrapper = mountWithGameState({ on_third: makeRunner(30) });
|
||||||
expect(outfieldDepth.value).toBe("normal");
|
const buttons = wrapper.findAll('button[type="button"]');
|
||||||
|
const ifInBtn = buttons.find(b => b.text() === "IF In");
|
||||||
|
expect(ifInBtn).toBeTruthy();
|
||||||
|
await ifInBtn!.trigger("click");
|
||||||
|
|
||||||
|
const { infieldDepth } = useDefensiveSetup();
|
||||||
|
expect(infieldDepth.value).toBe("infield_in");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Hold Runners Display", () => {
|
describe("Outfield Row (walk-off scenario)", () => {
|
||||||
it('shows "None" when no runners held in preview', () => {
|
it("hides outfield row in normal game situations", () => {
|
||||||
const wrapper = mount(DefensiveSetup, {
|
/**
|
||||||
props: defaultProps,
|
* Outfield row should be hidden when it's NOT a walk-off scenario.
|
||||||
|
* A normal mid-game state should not show "Outfield" or "Shallow".
|
||||||
|
*/
|
||||||
|
const wrapper = mountWithGameState({
|
||||||
|
inning: 5,
|
||||||
|
half: "top",
|
||||||
|
on_first: makeRunner(10),
|
||||||
});
|
});
|
||||||
|
expect(wrapper.text()).not.toContain("Outfield");
|
||||||
// Check preview section shows "None" for holding
|
expect(wrapper.text()).not.toContain("Shallow");
|
||||||
expect(wrapper.text()).toContain("Holding:None");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("displays holding status in preview for held runners", () => {
|
it("shows outfield row in walk-off scenario", () => {
|
||||||
/**
|
/**
|
||||||
* The preview section should show a comma-separated list of held bases.
|
* Walk-off scenario: bottom of 9th+, home team losing/tied, runners on base.
|
||||||
* Hold runner UI has moved to the runner pills themselves.
|
* This should show the outfield row with Normal and Shallow options.
|
||||||
*/
|
*/
|
||||||
const { syncFromDecision } = useDefensiveSetup();
|
const wrapper = mountWithGameState({
|
||||||
syncFromDecision({
|
inning: 9,
|
||||||
infield_depth: "normal",
|
half: "bottom",
|
||||||
outfield_depth: "normal",
|
home_score: 3,
|
||||||
hold_runners: [1, 3],
|
away_score: 4,
|
||||||
|
on_third: makeRunner(30),
|
||||||
});
|
});
|
||||||
|
expect(wrapper.text()).toContain("Outfield");
|
||||||
const wrapper = mount(DefensiveSetup, {
|
expect(wrapper.text()).toContain("Normal");
|
||||||
props: defaultProps,
|
expect(wrapper.text()).toContain("Shallow");
|
||||||
});
|
|
||||||
|
|
||||||
// Preview should show the held bases
|
|
||||||
expect(wrapper.text()).toContain("Holding:1st, 3rd");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("displays holding status in preview for multiple runners", () => {
|
it("hides outfield row when home is winning in bottom of 9th", () => {
|
||||||
/**
|
/**
|
||||||
* The preview section should show a comma-separated list of held bases.
|
* If home team is already ahead, it's not a walk-off scenario.
|
||||||
*/
|
*/
|
||||||
const { syncFromDecision } = useDefensiveSetup();
|
const wrapper = mountWithGameState({
|
||||||
syncFromDecision({
|
inning: 9,
|
||||||
infield_depth: "normal",
|
half: "bottom",
|
||||||
outfield_depth: "normal",
|
home_score: 5,
|
||||||
hold_runners: [1, 2, 3],
|
away_score: 3,
|
||||||
|
on_first: makeRunner(10),
|
||||||
});
|
});
|
||||||
|
expect(wrapper.text()).not.toContain("Outfield");
|
||||||
const wrapper = mount(DefensiveSetup, {
|
expect(wrapper.text()).not.toContain("Shallow");
|
||||||
props: defaultProps,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain("Holding:1st, 2nd, 3rd");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Preview Display", () => {
|
describe("Hold Runner Toggles", () => {
|
||||||
it("displays current infield depth in preview", () => {
|
it("shows hold pills only for occupied bases", () => {
|
||||||
const { syncFromDecision } = useDefensiveSetup();
|
/**
|
||||||
syncFromDecision({
|
* Hold toggle pills should only appear for bases that have runners.
|
||||||
infield_depth: "infield_in",
|
* Empty bases should not show a pill at all (not disabled, just absent).
|
||||||
outfield_depth: "normal",
|
*/
|
||||||
hold_runners: [],
|
const wrapper = mountWithGameState({
|
||||||
|
on_first: makeRunner(10),
|
||||||
|
on_third: makeRunner(30),
|
||||||
});
|
});
|
||||||
|
expect(wrapper.text()).toContain("Hold");
|
||||||
|
expect(wrapper.text()).toContain("1B");
|
||||||
|
expect(wrapper.text()).toContain("3B");
|
||||||
|
expect(wrapper.text()).not.toContain("2B");
|
||||||
|
});
|
||||||
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
it("hides hold row when bases are empty", () => {
|
||||||
props: {
|
/**
|
||||||
...defaultProps,
|
* When no runners on base, the hold row shouldn't render at all.
|
||||||
gameState: {
|
*/
|
||||||
on_third: 123, // Need runner on third for infield_in option
|
const wrapper = mountWithGameState({
|
||||||
} as any,
|
on_first: null,
|
||||||
},
|
on_second: null,
|
||||||
|
on_third: null,
|
||||||
});
|
});
|
||||||
|
expect(wrapper.text()).not.toContain("Hold");
|
||||||
|
expect(wrapper.text()).not.toContain("1B");
|
||||||
|
expect(wrapper.text()).not.toContain("2B");
|
||||||
|
expect(wrapper.text()).not.toContain("3B");
|
||||||
|
});
|
||||||
|
|
||||||
expect(wrapper.text()).toContain("Infield In");
|
it("shows all three hold pills when bases loaded", () => {
|
||||||
|
const wrapper = mountWithGameState({
|
||||||
|
on_first: makeRunner(10),
|
||||||
|
on_second: makeRunner(20),
|
||||||
|
on_third: makeRunner(30),
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).toContain("1B");
|
||||||
|
expect(wrapper.text()).toContain("2B");
|
||||||
|
expect(wrapper.text()).toContain("3B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles hold state when pill is clicked", async () => {
|
||||||
|
/**
|
||||||
|
* Clicking a hold pill should toggle that base's hold state
|
||||||
|
* in the shared useDefensiveSetup composable.
|
||||||
|
*/
|
||||||
|
const wrapper = mountWithGameState({ on_first: makeRunner(10) });
|
||||||
|
const holdBtn = wrapper.findAll('button[type="button"]').find(b => b.text() === "1B");
|
||||||
|
expect(holdBtn).toBeTruthy();
|
||||||
|
|
||||||
|
await holdBtn!.trigger("click");
|
||||||
|
|
||||||
|
const { isHeld } = useDefensiveSetup();
|
||||||
|
expect(isHeld(1)).toBe(true);
|
||||||
|
|
||||||
|
// Click again to toggle off
|
||||||
|
await holdBtn!.trigger("click");
|
||||||
|
expect(isHeld(1)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -168,29 +290,19 @@ describe("DefensiveSetup", () => {
|
|||||||
hold_runners: [2],
|
hold_runners: [2],
|
||||||
});
|
});
|
||||||
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
const wrapper = mountWithGameState();
|
||||||
props: defaultProps,
|
|
||||||
});
|
|
||||||
|
|
||||||
await wrapper.find("form").trigger("submit.prevent");
|
await wrapper.find("form").trigger("submit.prevent");
|
||||||
|
|
||||||
expect(wrapper.emitted("submit")).toBeTruthy();
|
expect(wrapper.emitted("submit")).toBeTruthy();
|
||||||
const emitted = wrapper.emitted(
|
const emitted = wrapper.emitted("submit")![0][0] as DefensiveDecision;
|
||||||
"submit",
|
|
||||||
)![0][0] as DefensiveDecision;
|
|
||||||
expect(emitted.infield_depth).toBe("normal");
|
expect(emitted.infield_depth).toBe("normal");
|
||||||
expect(emitted.outfield_depth).toBe("normal");
|
expect(emitted.outfield_depth).toBe("normal");
|
||||||
expect(emitted.hold_runners).toEqual([2]);
|
expect(emitted.hold_runners).toEqual([2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not submit when not active", async () => {
|
it("does not submit when not active", async () => {
|
||||||
const wrapper = mount(DefensiveSetup, {
|
const wrapper = mountWithGameState({}, { isActive: false });
|
||||||
props: {
|
|
||||||
...defaultProps,
|
|
||||||
isActive: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await wrapper.find("form").trigger("submit.prevent");
|
await wrapper.find("form").trigger("submit.prevent");
|
||||||
expect(wrapper.emitted("submit")).toBeFalsy();
|
expect(wrapper.emitted("submit")).toBeFalsy();
|
||||||
});
|
});
|
||||||
@ -200,53 +312,15 @@ describe("DefensiveSetup", () => {
|
|||||||
* Submitting with defaults should emit a valid DefensiveDecision
|
* Submitting with defaults should emit a valid DefensiveDecision
|
||||||
* with normal depth and no held runners.
|
* with normal depth and no held runners.
|
||||||
*/
|
*/
|
||||||
const wrapper = mount(DefensiveSetup, {
|
const wrapper = mountWithGameState();
|
||||||
props: defaultProps,
|
|
||||||
});
|
|
||||||
|
|
||||||
await wrapper.find("form").trigger("submit.prevent");
|
await wrapper.find("form").trigger("submit.prevent");
|
||||||
expect(wrapper.emitted("submit")).toBeTruthy();
|
expect(wrapper.emitted("submit")).toBeTruthy();
|
||||||
const emitted = wrapper.emitted(
|
const emitted = wrapper.emitted("submit")![0][0] as DefensiveDecision;
|
||||||
"submit",
|
|
||||||
)![0][0] as DefensiveDecision;
|
|
||||||
expect(emitted.infield_depth).toBe("normal");
|
expect(emitted.infield_depth).toBe("normal");
|
||||||
expect(emitted.outfield_depth).toBe("normal");
|
expect(emitted.outfield_depth).toBe("normal");
|
||||||
expect(emitted.hold_runners).toEqual([]);
|
expect(emitted.hold_runners).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows loading state during submission", async () => {
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
|
||||||
props: defaultProps,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger submission
|
|
||||||
wrapper.vm.submitting = true;
|
|
||||||
await wrapper.vm.$nextTick();
|
|
||||||
|
|
||||||
// Verify button is in loading state
|
|
||||||
expect(wrapper.vm.submitting).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Submit Button State", () => {
|
|
||||||
it('shows "Wait for Your Turn" when not active', () => {
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
|
||||||
props: {
|
|
||||||
...defaultProps,
|
|
||||||
isActive: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(wrapper.vm.submitButtonText).toBe("Wait for Your Turn");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows "Submit Defensive Setup" when active', () => {
|
|
||||||
const wrapper = mount(DefensiveSetup, {
|
|
||||||
props: defaultProps,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(wrapper.vm.submitButtonText).toBe("Submit Defensive Setup");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Prop Updates", () => {
|
describe("Prop Updates", () => {
|
||||||
@ -255,9 +329,7 @@ describe("DefensiveSetup", () => {
|
|||||||
* When the parent updates the currentSetup prop (e.g. from server state),
|
* When the parent updates the currentSetup prop (e.g. from server state),
|
||||||
* the composable should be synced to match.
|
* the composable should be synced to match.
|
||||||
*/
|
*/
|
||||||
const wrapper = mount(DefensiveSetup, {
|
const wrapper = mountWithGameState();
|
||||||
props: defaultProps,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newSetup: DefensiveDecision = {
|
const newSetup: DefensiveDecision = {
|
||||||
infield_depth: "infield_in",
|
infield_depth: "infield_in",
|
||||||
@ -267,8 +339,7 @@ describe("DefensiveSetup", () => {
|
|||||||
|
|
||||||
await wrapper.setProps({ currentSetup: newSetup });
|
await wrapper.setProps({ currentSetup: newSetup });
|
||||||
|
|
||||||
const { infieldDepth, outfieldDepth, holdRunnersArray } =
|
const { infieldDepth, outfieldDepth, holdRunnersArray } = useDefensiveSetup();
|
||||||
useDefensiveSetup();
|
|
||||||
expect(infieldDepth.value).toBe("infield_in");
|
expect(infieldDepth.value).toBe("infield_in");
|
||||||
expect(outfieldDepth.value).toBe("normal");
|
expect(outfieldDepth.value).toBe("normal");
|
||||||
expect(holdRunnersArray.value).toEqual([1, 2, 3]);
|
expect(holdRunnersArray.value).toEqual([1, 2, 3]);
|
||||||
@ -276,20 +347,36 @@ describe("DefensiveSetup", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Disabled State", () => {
|
describe("Disabled State", () => {
|
||||||
it("disables depth controls when not active", () => {
|
it("disables all interactive buttons when not active", () => {
|
||||||
const wrapper = mount(DefensiveSetup, {
|
/**
|
||||||
props: {
|
* When isActive is false, all segmented control buttons and hold pills
|
||||||
...defaultProps,
|
* should be disabled.
|
||||||
isActive: false,
|
*/
|
||||||
},
|
const wrapper = mountWithGameState(
|
||||||
});
|
{ on_first: makeRunner(10) },
|
||||||
|
{ isActive: false },
|
||||||
const buttonGroups = wrapper.findAllComponents({
|
);
|
||||||
name: "ButtonGroup",
|
const buttons = wrapper.findAll('button[type="button"]');
|
||||||
});
|
buttons.forEach((btn) => {
|
||||||
buttonGroups.forEach((bg) => {
|
expect(btn.attributes("disabled")).toBeDefined();
|
||||||
expect(bg.props("disabled")).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Game State from Store (bug fix verification)", () => {
|
||||||
|
it("reads game state from store, not from props", () => {
|
||||||
|
/**
|
||||||
|
* The old DefensiveSetup used a gameState prop that was never passed by
|
||||||
|
* DecisionPanel, so conditional options (infield_in, corners_in, shallow)
|
||||||
|
* never appeared. The new version reads from useGameStore() directly.
|
||||||
|
*
|
||||||
|
* This test verifies the fix by setting store state with runner on 3rd
|
||||||
|
* WITHOUT passing any gameState prop, and expecting infield options to appear.
|
||||||
|
*/
|
||||||
|
const wrapper = mountWithGameState({ on_third: makeRunner(30) });
|
||||||
|
// No gameState prop passed — component reads from store
|
||||||
|
expect(wrapper.text()).toContain("IF In");
|
||||||
|
expect(wrapper.text()).toContain("Corners");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,325 +1,381 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from "@vue/test-utils";
|
||||||
import OffensiveApproach from '~/components/Decisions/OffensiveApproach.vue'
|
import { createPinia, setActivePinia } from "pinia";
|
||||||
|
import OffensiveApproach from "~/components/Decisions/OffensiveApproach.vue";
|
||||||
|
import { useGameStore } from "~/store/game";
|
||||||
|
import type { GameState, OffensiveDecision } from "~/types/game";
|
||||||
|
|
||||||
// TODO: Fix form interaction and text rendering issues
|
/**
|
||||||
describe.skip('OffensiveApproach', () => {
|
* Creates a minimal GameState for testing, with sensible defaults.
|
||||||
const defaultProps = {
|
* Only override the fields you care about per test.
|
||||||
gameId: 'test-game-123',
|
*/
|
||||||
isActive: true,
|
function makeGameState(overrides: Partial<GameState> = {}): GameState {
|
||||||
}
|
return {
|
||||||
|
game_id: "test-game-123",
|
||||||
|
league_id: "sba",
|
||||||
|
home_team_id: 1,
|
||||||
|
away_team_id: 2,
|
||||||
|
home_team_is_ai: false,
|
||||||
|
away_team_is_ai: false,
|
||||||
|
creator_discord_id: null,
|
||||||
|
auto_mode: false,
|
||||||
|
status: "active",
|
||||||
|
inning: 5,
|
||||||
|
half: "top",
|
||||||
|
outs: 0,
|
||||||
|
balls: 0,
|
||||||
|
strikes: 0,
|
||||||
|
home_score: 3,
|
||||||
|
away_score: 2,
|
||||||
|
on_first: null,
|
||||||
|
on_second: null,
|
||||||
|
on_third: null,
|
||||||
|
current_batter: { lineup_id: 10, card_id: 100, position: "LF", batting_order: 1, is_active: true },
|
||||||
|
current_pitcher: { lineup_id: 20, card_id: 200, position: "P", batting_order: null, is_active: true },
|
||||||
|
current_catcher: null,
|
||||||
|
current_on_base_code: 0,
|
||||||
|
away_team_batter_idx: 0,
|
||||||
|
home_team_batter_idx: 0,
|
||||||
|
pending_decision: null,
|
||||||
|
decision_phase: "awaiting_offensive",
|
||||||
|
decisions_this_play: {},
|
||||||
|
pending_defensive_decision: null,
|
||||||
|
pending_offensive_decision: null,
|
||||||
|
pending_manual_roll: null,
|
||||||
|
pending_x_check: null,
|
||||||
|
pending_uncapped_hit: null,
|
||||||
|
play_count: 0,
|
||||||
|
last_play_result: null,
|
||||||
|
created_at: "2025-01-01T00:00:00Z",
|
||||||
|
started_at: "2025-01-01T00:00:00Z",
|
||||||
|
completed_at: null,
|
||||||
|
...overrides,
|
||||||
|
} as GameState;
|
||||||
|
}
|
||||||
|
|
||||||
describe('Rendering', () => {
|
/** Helper to create a mock runner LineupPlayerState */
|
||||||
it('renders all approach options', () => {
|
function makeRunner(lineupId: number, position: string = "LF") {
|
||||||
const wrapper = mount(OffensiveApproach, {
|
return { lineup_id: lineupId, card_id: lineupId * 10, position, batting_order: 1, is_active: true };
|
||||||
props: defaultProps,
|
}
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Normal')
|
describe("OffensiveApproach", () => {
|
||||||
expect(wrapper.text()).toContain('Contact')
|
let pinia: ReturnType<typeof createPinia>;
|
||||||
expect(wrapper.text()).toContain('Power')
|
|
||||||
expect(wrapper.text()).toContain('Patient')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders special tactics section', () => {
|
const defaultProps = {
|
||||||
const wrapper = mount(OffensiveApproach, {
|
gameId: "test-game-123",
|
||||||
props: defaultProps,
|
isActive: true,
|
||||||
})
|
};
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Hit and Run')
|
beforeEach(() => {
|
||||||
expect(wrapper.text()).toContain('Bunt Attempt')
|
pinia = createPinia();
|
||||||
})
|
setActivePinia(pinia);
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Approach Selection', () => {
|
/**
|
||||||
it('selects normal approach by default', () => {
|
* Helper: mount with game state injected into the store.
|
||||||
const wrapper = mount(OffensiveApproach, {
|
* Sets up Pinia and populates gameStore.gameState before mounting.
|
||||||
props: defaultProps,
|
*/
|
||||||
})
|
function mountWithGameState(gameStateOverrides: Partial<GameState> = {}, propsOverrides: Record<string, any> = {}) {
|
||||||
|
const gameStore = useGameStore();
|
||||||
|
gameStore.setGameState(makeGameState(gameStateOverrides));
|
||||||
|
return mount(OffensiveApproach, {
|
||||||
|
props: { ...defaultProps, ...propsOverrides },
|
||||||
|
global: { plugins: [pinia] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
expect(wrapper.vm.localDecision.approach).toBe('normal')
|
describe("Rendering", () => {
|
||||||
})
|
it("always shows Swing Away option", () => {
|
||||||
|
/**
|
||||||
|
* Swing Away is the default action and should always be visible
|
||||||
|
* regardless of game state.
|
||||||
|
*/
|
||||||
|
const wrapper = mountWithGameState();
|
||||||
|
expect(wrapper.text()).toContain("Swing Away");
|
||||||
|
});
|
||||||
|
|
||||||
it('uses provided currentDecision approach', () => {
|
it("renders offensive action header", () => {
|
||||||
const wrapper = mount(OffensiveApproach, {
|
const wrapper = mountWithGameState();
|
||||||
props: {
|
expect(wrapper.text()).toContain("Offensive Action");
|
||||||
...defaultProps,
|
});
|
||||||
currentDecision: {
|
|
||||||
approach: 'power',
|
|
||||||
hit_and_run: false,
|
|
||||||
bunt_attempt: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.vm.localDecision.approach).toBe('power')
|
it("shows Opponent's Turn badge when not active", () => {
|
||||||
})
|
const wrapper = mountWithGameState({}, { isActive: false });
|
||||||
|
expect(wrapper.text()).toContain("Opponent's Turn");
|
||||||
|
});
|
||||||
|
|
||||||
it('changes approach when button clicked', async () => {
|
it("hides Opponent's Turn badge when active", () => {
|
||||||
const wrapper = mount(OffensiveApproach, {
|
const wrapper = mountWithGameState();
|
||||||
props: defaultProps,
|
expect(wrapper.text()).not.toContain("Opponent's Turn");
|
||||||
})
|
});
|
||||||
|
});
|
||||||
|
|
||||||
wrapper.vm.selectApproach('contact')
|
describe("Check Jump (requires any runner on base)", () => {
|
||||||
await wrapper.vm.$nextTick()
|
it("hides Check Jump when bases empty", () => {
|
||||||
|
/**
|
||||||
|
* Check Jump requires at least one runner on base.
|
||||||
|
* With empty bases it should not appear at all.
|
||||||
|
*/
|
||||||
|
const wrapper = mountWithGameState({ on_first: null, on_second: null, on_third: null });
|
||||||
|
expect(wrapper.text()).not.toContain("Check Jump");
|
||||||
|
});
|
||||||
|
|
||||||
expect(wrapper.vm.localDecision.approach).toBe('contact')
|
it("shows Check Jump with runner on first", () => {
|
||||||
})
|
const wrapper = mountWithGameState({ on_first: makeRunner(10) });
|
||||||
|
expect(wrapper.text()).toContain("Check Jump");
|
||||||
|
});
|
||||||
|
|
||||||
it('does not change approach when not active', () => {
|
it("shows Check Jump with runner on second only", () => {
|
||||||
const wrapper = mount(OffensiveApproach, {
|
/**
|
||||||
props: {
|
* Even though hit-and-run is NOT available with runner on 2nd only,
|
||||||
...defaultProps,
|
* check jump IS available with any runner.
|
||||||
isActive: false,
|
*/
|
||||||
},
|
const wrapper = mountWithGameState({ on_second: makeRunner(20) });
|
||||||
})
|
expect(wrapper.text()).toContain("Check Jump");
|
||||||
|
});
|
||||||
|
|
||||||
const originalApproach = wrapper.vm.localDecision.approach
|
it("shows Check Jump with runner on third", () => {
|
||||||
wrapper.vm.selectApproach('power')
|
const wrapper = mountWithGameState({ on_third: makeRunner(30) });
|
||||||
|
expect(wrapper.text()).toContain("Check Jump");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(wrapper.vm.localDecision.approach).toBe(originalApproach)
|
describe("Hit and Run (requires runner on 1st and/or 3rd)", () => {
|
||||||
})
|
it("hides Hit and Run when bases empty", () => {
|
||||||
})
|
const wrapper = mountWithGameState();
|
||||||
|
expect(wrapper.text()).not.toContain("Hit and Run");
|
||||||
|
});
|
||||||
|
|
||||||
describe('Hit and Run', () => {
|
it("hides Hit and Run with runner on second only", () => {
|
||||||
it('is disabled when no runners on base', () => {
|
/**
|
||||||
const wrapper = mount(OffensiveApproach, {
|
* Hit and Run should NOT be available when only a runner on 2nd.
|
||||||
props: {
|
* The user's rule: "runner on first and/or third".
|
||||||
...defaultProps,
|
*/
|
||||||
hasRunnersOnBase: false,
|
const wrapper = mountWithGameState({ on_second: makeRunner(20) });
|
||||||
},
|
expect(wrapper.text()).not.toContain("Hit and Run");
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(wrapper.vm.canUseHitAndRun).toBe(false)
|
it("shows Hit and Run with runner on first", () => {
|
||||||
})
|
const wrapper = mountWithGameState({ on_first: makeRunner(10) });
|
||||||
|
expect(wrapper.text()).toContain("Hit and Run");
|
||||||
|
});
|
||||||
|
|
||||||
it('is enabled when runners on base', () => {
|
it("shows Hit and Run with runner on third", () => {
|
||||||
const wrapper = mount(OffensiveApproach, {
|
const wrapper = mountWithGameState({ on_third: makeRunner(30) });
|
||||||
props: {
|
expect(wrapper.text()).toContain("Hit and Run");
|
||||||
...defaultProps,
|
});
|
||||||
hasRunnersOnBase: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.vm.canUseHitAndRun).toBe(true)
|
it("shows Hit and Run with runners on first and third", () => {
|
||||||
})
|
const wrapper = mountWithGameState({
|
||||||
|
on_first: makeRunner(10),
|
||||||
|
on_third: makeRunner(30),
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).toContain("Hit and Run");
|
||||||
|
});
|
||||||
|
|
||||||
it('clears hit and run when runners removed', async () => {
|
it("shows Hit and Run with runner on first and second (first qualifies)", () => {
|
||||||
const wrapper = mount(OffensiveApproach, {
|
/**
|
||||||
props: {
|
* Runner on 1st satisfies the condition even if 2nd is also occupied.
|
||||||
...defaultProps,
|
*/
|
||||||
hasRunnersOnBase: true,
|
const wrapper = mountWithGameState({
|
||||||
},
|
on_first: makeRunner(10),
|
||||||
})
|
on_second: makeRunner(20),
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).toContain("Hit and Run");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
wrapper.vm.localDecision.hit_and_run = true
|
describe("Sacrifice Bunt (requires < 2 outs AND runners on base)", () => {
|
||||||
await wrapper.vm.$nextTick()
|
it("hides Sac Bunt when bases empty", () => {
|
||||||
|
/**
|
||||||
|
* Sac bunt requires on_base_code > 0 (runners on base).
|
||||||
|
*/
|
||||||
|
const wrapper = mountWithGameState({ outs: 0 });
|
||||||
|
expect(wrapper.text()).not.toContain("Sacrifice Bunt");
|
||||||
|
});
|
||||||
|
|
||||||
await wrapper.setProps({ hasRunnersOnBase: false })
|
it("hides Sac Bunt with 2 outs", () => {
|
||||||
|
const wrapper = mountWithGameState({ outs: 2, on_first: makeRunner(10) });
|
||||||
|
expect(wrapper.text()).not.toContain("Sacrifice Bunt");
|
||||||
|
});
|
||||||
|
|
||||||
expect(wrapper.vm.localDecision.hit_and_run).toBe(false)
|
it("shows Sac Bunt with 0 outs and runner on base", () => {
|
||||||
})
|
const wrapper = mountWithGameState({ outs: 0, on_first: makeRunner(10) });
|
||||||
})
|
expect(wrapper.text()).toContain("Sacrifice Bunt");
|
||||||
|
});
|
||||||
|
|
||||||
describe('Change Detection', () => {
|
it("shows Sac Bunt with 1 out and runner on base", () => {
|
||||||
it('detects approach changes', () => {
|
const wrapper = mountWithGameState({ outs: 1, on_second: makeRunner(20) });
|
||||||
const wrapper = mount(OffensiveApproach, {
|
expect(wrapper.text()).toContain("Sacrifice Bunt");
|
||||||
props: {
|
});
|
||||||
...defaultProps,
|
});
|
||||||
currentDecision: {
|
|
||||||
approach: 'normal',
|
|
||||||
hit_and_run: false,
|
|
||||||
bunt_attempt: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.vm.hasChanges).toBe(false)
|
describe("Squeeze Bunt (requires < 2 outs AND runner on 3rd)", () => {
|
||||||
|
it("hides Squeeze Bunt when no runner on third", () => {
|
||||||
|
const wrapper = mountWithGameState({ outs: 0, on_first: makeRunner(10) });
|
||||||
|
expect(wrapper.text()).not.toContain("Squeeze Bunt");
|
||||||
|
});
|
||||||
|
|
||||||
wrapper.vm.localDecision.approach = 'power'
|
it("hides Squeeze Bunt with 2 outs even if runner on third", () => {
|
||||||
expect(wrapper.vm.hasChanges).toBe(true)
|
const wrapper = mountWithGameState({ outs: 2, on_third: makeRunner(30) });
|
||||||
})
|
expect(wrapper.text()).not.toContain("Squeeze Bunt");
|
||||||
|
});
|
||||||
|
|
||||||
it('detects hit and run changes', () => {
|
it("shows Squeeze Bunt with 0 outs and runner on third", () => {
|
||||||
const wrapper = mount(OffensiveApproach, {
|
const wrapper = mountWithGameState({ outs: 0, on_third: makeRunner(30) });
|
||||||
props: {
|
expect(wrapper.text()).toContain("Squeeze Bunt");
|
||||||
...defaultProps,
|
});
|
||||||
currentDecision: {
|
|
||||||
approach: 'normal',
|
|
||||||
hit_and_run: false,
|
|
||||||
bunt_attempt: false,
|
|
||||||
},
|
|
||||||
hasRunnersOnBase: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.vm.hasChanges).toBe(false)
|
it("shows Squeeze Bunt with 1 out and runner on third", () => {
|
||||||
|
const wrapper = mountWithGameState({ outs: 1, on_third: makeRunner(30) });
|
||||||
|
expect(wrapper.text()).toContain("Squeeze Bunt");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
wrapper.vm.localDecision.hit_and_run = true
|
describe("Steal option removed", () => {
|
||||||
expect(wrapper.vm.hasChanges).toBe(true)
|
it("does not show Steal option even with runners on base", () => {
|
||||||
})
|
/**
|
||||||
|
* The steal option was removed — check jump encompasses steal behavior.
|
||||||
|
*/
|
||||||
|
const wrapper = mountWithGameState({ on_first: makeRunner(10) });
|
||||||
|
const actionLabels = wrapper.findAll("button[type='button']").map(b => b.text());
|
||||||
|
const hasStealLabel = actionLabels.some(label => label.includes("Steal") && !label.includes("Bunt"));
|
||||||
|
expect(hasStealLabel).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('detects bunt attempt changes', () => {
|
describe("Full scenario: all options visible", () => {
|
||||||
const wrapper = mount(OffensiveApproach, {
|
it("shows all 5 options with 0 outs and runners on 1st and 3rd", () => {
|
||||||
props: {
|
/**
|
||||||
...defaultProps,
|
* With 0 outs, runner on 1st AND 3rd: all conditions are met.
|
||||||
currentDecision: {
|
* Should show: Swing Away, Check Jump, Hit and Run, Sac Bunt, Squeeze Bunt.
|
||||||
approach: 'normal',
|
*/
|
||||||
hit_and_run: false,
|
const wrapper = mountWithGameState({
|
||||||
bunt_attempt: false,
|
outs: 0,
|
||||||
},
|
on_first: makeRunner(10),
|
||||||
},
|
on_third: makeRunner(30),
|
||||||
})
|
});
|
||||||
|
expect(wrapper.text()).toContain("Swing Away");
|
||||||
|
expect(wrapper.text()).toContain("Check Jump");
|
||||||
|
expect(wrapper.text()).toContain("Hit and Run");
|
||||||
|
expect(wrapper.text()).toContain("Sacrifice Bunt");
|
||||||
|
expect(wrapper.text()).toContain("Squeeze Bunt");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(wrapper.vm.hasChanges).toBe(false)
|
describe("Action Selection", () => {
|
||||||
|
it("selects swing_away by default", () => {
|
||||||
|
const wrapper = mountWithGameState({ on_first: makeRunner(10) });
|
||||||
|
// The checkmark should be next to Swing Away
|
||||||
|
const buttons = wrapper.findAll("button[type='button']");
|
||||||
|
const swingBtn = buttons.find(b => b.text().includes("Swing Away"));
|
||||||
|
expect(swingBtn?.text()).toContain("✓");
|
||||||
|
});
|
||||||
|
|
||||||
wrapper.vm.localDecision.bunt_attempt = true
|
it("can select check_jump when available", async () => {
|
||||||
expect(wrapper.vm.hasChanges).toBe(true)
|
const wrapper = mountWithGameState({ on_first: makeRunner(10) });
|
||||||
})
|
const buttons = wrapper.findAll("button[type='button']");
|
||||||
})
|
const checkJumpBtn = buttons.find(b => b.text().includes("Check Jump"));
|
||||||
|
expect(checkJumpBtn).toBeTruthy();
|
||||||
|
|
||||||
describe('Form Submission', () => {
|
await checkJumpBtn!.trigger("click");
|
||||||
it('emits submit with decision', async () => {
|
|
||||||
const wrapper = mount(OffensiveApproach, {
|
|
||||||
props: defaultProps,
|
|
||||||
})
|
|
||||||
|
|
||||||
wrapper.vm.localDecision = {
|
// Checkmark should move to Check Jump
|
||||||
approach: 'power',
|
expect(checkJumpBtn!.text()).toContain("✓");
|
||||||
hit_and_run: false,
|
});
|
||||||
bunt_attempt: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
await wrapper.find('form').trigger('submit.prevent')
|
it("does not change action when not active", async () => {
|
||||||
|
const wrapper = mountWithGameState({ on_first: makeRunner(10) }, { isActive: false });
|
||||||
|
const buttons = wrapper.findAll("button[type='button']");
|
||||||
|
const checkJumpBtn = buttons.find(b => b.text().includes("Check Jump"));
|
||||||
|
|
||||||
expect(wrapper.emitted('submit')).toBeTruthy()
|
await checkJumpBtn!.trigger("click");
|
||||||
const emitted = wrapper.emitted('submit')![0][0]
|
|
||||||
expect(emitted).toEqual({
|
|
||||||
approach: 'power',
|
|
||||||
hit_and_run: false,
|
|
||||||
bunt_attempt: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not submit when not active', async () => {
|
// Swing Away should still be selected (checkmark stays)
|
||||||
const wrapper = mount(OffensiveApproach, {
|
const swingBtn = buttons.find(b => b.text().includes("Swing Away"));
|
||||||
props: {
|
expect(swingBtn?.text()).toContain("✓");
|
||||||
...defaultProps,
|
});
|
||||||
isActive: false,
|
});
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await wrapper.find('form').trigger('submit.prevent')
|
describe("Auto-reset when action becomes unavailable", () => {
|
||||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
it("resets to swing_away when selected action disappears", async () => {
|
||||||
})
|
/**
|
||||||
|
* If a user selects sac_bunt but then the game state changes
|
||||||
|
* (e.g., outs increase to 2), the selection should reset to swing_away.
|
||||||
|
*/
|
||||||
|
const gameStore = useGameStore();
|
||||||
|
gameStore.setGameState(makeGameState({ outs: 0, on_first: makeRunner(10) }));
|
||||||
|
|
||||||
it('does not submit when no changes', async () => {
|
const wrapper = mount(OffensiveApproach, {
|
||||||
const wrapper = mount(OffensiveApproach, {
|
props: defaultProps,
|
||||||
props: {
|
global: { plugins: [pinia] },
|
||||||
...defaultProps,
|
});
|
||||||
currentDecision: {
|
|
||||||
approach: 'normal',
|
|
||||||
hit_and_run: false,
|
|
||||||
bunt_attempt: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await wrapper.find('form').trigger('submit.prevent')
|
// Select sac bunt
|
||||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
const sacBuntBtn = wrapper.findAll("button[type='button']").find(b => b.text().includes("Sacrifice Bunt"));
|
||||||
})
|
await sacBuntBtn!.trigger("click");
|
||||||
})
|
|
||||||
|
|
||||||
describe('Display Text', () => {
|
// Now change game state to 2 outs — sac bunt should disappear
|
||||||
it('shows current approach label', () => {
|
gameStore.setGameState(makeGameState({ outs: 2, on_first: makeRunner(10) }));
|
||||||
const wrapper = mount(OffensiveApproach, {
|
await wrapper.vm.$nextTick();
|
||||||
props: {
|
|
||||||
...defaultProps,
|
|
||||||
currentDecision: {
|
|
||||||
approach: 'contact',
|
|
||||||
hit_and_run: false,
|
|
||||||
bunt_attempt: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.vm.currentApproachLabel).toBe('Contact')
|
// Action should auto-reset to swing_away
|
||||||
})
|
const swingBtn = wrapper.findAll("button[type='button']").find(b => b.text().includes("Swing Away"));
|
||||||
|
expect(swingBtn?.text()).toContain("✓");
|
||||||
|
expect(wrapper.text()).not.toContain("Sacrifice Bunt");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('shows active tactics', () => {
|
describe("Form Submission", () => {
|
||||||
const wrapper = mount(OffensiveApproach, {
|
it("emits submit with current action", async () => {
|
||||||
props: defaultProps,
|
const wrapper = mountWithGameState({ on_first: makeRunner(10) });
|
||||||
})
|
|
||||||
|
|
||||||
wrapper.vm.localDecision.hit_and_run = true
|
// Select check_jump
|
||||||
wrapper.vm.localDecision.bunt_attempt = true
|
const checkJumpBtn = wrapper.findAll("button[type='button']").find(b => b.text().includes("Check Jump"));
|
||||||
|
await checkJumpBtn!.trigger("click");
|
||||||
|
|
||||||
expect(wrapper.vm.activeTactics).toBe('Hit & Run, Bunt')
|
await wrapper.find("form").trigger("submit.prevent");
|
||||||
})
|
|
||||||
|
|
||||||
it('shows None when no tactics active', () => {
|
expect(wrapper.emitted("submit")).toBeTruthy();
|
||||||
const wrapper = mount(OffensiveApproach, {
|
const emitted = wrapper.emitted("submit")![0][0] as Omit<OffensiveDecision, "steal_attempts">;
|
||||||
props: defaultProps,
|
expect(emitted.action).toBe("check_jump");
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(wrapper.vm.activeTactics).toBe('None')
|
it("does not submit when not active", async () => {
|
||||||
})
|
const wrapper = mountWithGameState({}, { isActive: false });
|
||||||
})
|
await wrapper.find("form").trigger("submit.prevent");
|
||||||
|
expect(wrapper.emitted("submit")).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
describe('Submit Button Text', () => {
|
it("submits default swing_away when no changes made", async () => {
|
||||||
it('shows wait message when not active', () => {
|
const wrapper = mountWithGameState();
|
||||||
const wrapper = mount(OffensiveApproach, {
|
await wrapper.find("form").trigger("submit.prevent");
|
||||||
props: {
|
expect(wrapper.emitted("submit")).toBeTruthy();
|
||||||
...defaultProps,
|
const emitted = wrapper.emitted("submit")![0][0] as Omit<OffensiveDecision, "steal_attempts">;
|
||||||
isActive: false,
|
expect(emitted.action).toBe("swing_away");
|
||||||
},
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(wrapper.vm.submitButtonText).toBe('Wait for Your Turn')
|
describe("Game State from Store (bug fix verification)", () => {
|
||||||
})
|
it("reads game state from store, not from props", () => {
|
||||||
|
/**
|
||||||
it('shows no changes message', () => {
|
* The old OffensiveApproach used props like runnerOnFirst, runnerOnThird,
|
||||||
const wrapper = mount(OffensiveApproach, {
|
* and outs that were never passed by DecisionPanel (all defaulted to false/0).
|
||||||
props: {
|
* The new version reads from useGameStore() directly.
|
||||||
...defaultProps,
|
*
|
||||||
currentDecision: {
|
* This test verifies the fix by setting store state with runners and outs
|
||||||
approach: 'normal',
|
* WITHOUT passing any runner/outs props, and expecting correct filtering.
|
||||||
hit_and_run: false,
|
*/
|
||||||
bunt_attempt: false,
|
const wrapper = mountWithGameState({
|
||||||
},
|
outs: 0,
|
||||||
},
|
on_first: makeRunner(10),
|
||||||
})
|
on_third: makeRunner(30),
|
||||||
|
});
|
||||||
expect(wrapper.vm.submitButtonText).toBe('No Changes')
|
// No runner props passed — component reads from store
|
||||||
})
|
expect(wrapper.text()).toContain("Check Jump");
|
||||||
|
expect(wrapper.text()).toContain("Hit and Run");
|
||||||
it('shows submit message when active with changes', () => {
|
expect(wrapper.text()).toContain("Sacrifice Bunt");
|
||||||
const wrapper = mount(OffensiveApproach, {
|
expect(wrapper.text()).toContain("Squeeze Bunt");
|
||||||
props: defaultProps,
|
});
|
||||||
})
|
});
|
||||||
|
});
|
||||||
wrapper.vm.localDecision.approach = 'power'
|
|
||||||
expect(wrapper.vm.submitButtonText).toBe('Submit Offensive Strategy')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Prop Updates', () => {
|
|
||||||
it('updates local state when currentDecision changes', async () => {
|
|
||||||
const wrapper = mount(OffensiveApproach, {
|
|
||||||
props: defaultProps,
|
|
||||||
})
|
|
||||||
|
|
||||||
await wrapper.setProps({
|
|
||||||
currentDecision: {
|
|
||||||
approach: 'patient',
|
|
||||||
hit_and_run: true,
|
|
||||||
bunt_attempt: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.vm.localDecision.approach).toBe('patient')
|
|
||||||
expect(wrapper.vm.localDecision.hit_and_run).toBe(true)
|
|
||||||
expect(wrapper.vm.localDecision.bunt_attempt).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@ -307,10 +307,10 @@ describe("RunnerCard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("hold runner icon", () => {
|
describe("hold runner icon", () => {
|
||||||
it("does not show hold icon by default", () => {
|
it("always shows hold pill for occupied bases", () => {
|
||||||
/**
|
/**
|
||||||
* When neither isHeld nor holdInteractive is set, the hold icon
|
* The hold pill is always visible for occupied bases as a status indicator.
|
||||||
* should not appear — keeps the pill clean for non-defensive contexts.
|
* It's non-interactive (disabled) outside the defensive phase.
|
||||||
*/
|
*/
|
||||||
const wrapper = mount(RunnerCard, {
|
const wrapper = mount(RunnerCard, {
|
||||||
global: { plugins: [pinia] },
|
global: { plugins: [pinia] },
|
||||||
@ -322,7 +322,8 @@ describe("RunnerCard", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(wrapper.find(".hold-icon").exists()).toBe(false);
|
expect(wrapper.find(".hold-icon").exists()).toBe(true);
|
||||||
|
expect(wrapper.find(".hold-icon").attributes("disabled")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows hold icon when holdInteractive is true", () => {
|
it("shows hold icon when holdInteractive is true", () => {
|
||||||
|
|||||||
387
mockups/defensive-setup-compact.html
Normal file
387
mockups/defensive-setup-compact.html
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Defensive Setup - Compact Design Mockups</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
body { background: #111827; }
|
||||||
|
.mockup-card { max-width: 400px; margin: 0 auto; }
|
||||||
|
/* Simulate the primary blue gradient for selected buttons */
|
||||||
|
.btn-selected { background: linear-gradient(to right, #1e40af, #2563eb); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="text-gray-100 p-4">
|
||||||
|
|
||||||
|
<div class="max-w-lg mx-auto space-y-12">
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-center text-white mb-2">Defensive Setup — Compact Mockups</h1>
|
||||||
|
<p class="text-sm text-gray-400 text-center mb-8">Mobile-first (400px max). Dark mode only for brevity.</p>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- SCENARIO: NO CHOICES (bases empty, normal game state) -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="border-t border-gray-700 pt-6">
|
||||||
|
<h2 class="text-lg font-bold text-yellow-400 mb-1">Scenario: No Choices Available</h2>
|
||||||
|
<p class="text-xs text-gray-400 mb-4">Bases empty, not a walk-off situation. Both infield and outfield only have "Normal".</p>
|
||||||
|
|
||||||
|
<div class="mockup-card space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500">ALL DESIGNS → Component hidden entirely</h3>
|
||||||
|
<div class="bg-gray-800 rounded-xl p-4 text-center border border-dashed border-gray-600">
|
||||||
|
<p class="text-sm text-gray-500 italic">Nothing renders here. The defensive setup component is completely removed from the DOM.</p>
|
||||||
|
<p class="text-xs text-gray-600 mt-2">The DecisionPanel auto-submits default values (normal/normal/no holds) and skips to offensive phase.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- SCENARIO: RUNNER ON 3RD ONLY (infield choices) -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="border-t border-gray-700 pt-6">
|
||||||
|
<h2 class="text-lg font-bold text-yellow-400 mb-1">Scenario: Runner on 3rd</h2>
|
||||||
|
<p class="text-xs text-gray-400 mb-6">Infield depth matters (3 options). Outfield still only "Normal". Hold runners available for R3.</p>
|
||||||
|
|
||||||
|
<!-- DESIGN A: Inline Segmented Control -->
|
||||||
|
<div class="mockup-card mb-8">
|
||||||
|
<h3 class="text-sm font-semibold text-emerald-400 mb-3">Design A — Inline Segmented Controls</h3>
|
||||||
|
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3">
|
||||||
|
<!-- Header row: icon + title + submit in one line -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-base font-bold text-white flex items-center gap-2">
|
||||||
|
🛡️ Defense
|
||||||
|
</h3>
|
||||||
|
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors">
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Infield: label + horizontal pill buttons -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
|
||||||
|
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium bg-gray-700 text-gray-300 hover:bg-gray-600 border-r border-gray-600">Normal</button>
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white">IF In</button>
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium bg-gray-700 text-gray-300 hover:bg-gray-600">Corners</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hold runners: inline toggle chips -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-gray-600 bg-gray-700 text-gray-400 opacity-40 cursor-not-allowed" disabled>1B</button>
|
||||||
|
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-gray-600 bg-gray-700 text-gray-400 opacity-40 cursor-not-allowed" disabled>2B</button>
|
||||||
|
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">3B</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DESIGN B: Single-Row Compact -->
|
||||||
|
<div class="mockup-card mb-8">
|
||||||
|
<h3 class="text-sm font-semibold text-emerald-400 mb-3">Design B — Ultra Compact (Single Card Row)</h3>
|
||||||
|
<div class="bg-gray-800 rounded-xl shadow-lg p-3 space-y-2">
|
||||||
|
<!-- Everything stacked tightly -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-bold text-white">🛡️ Defense</span>
|
||||||
|
<button class="px-3 py-1 text-xs font-semibold bg-green-600 text-white rounded-md">Confirm</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<!-- Infield as compact segmented -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-1">Infield</div>
|
||||||
|
<div class="flex rounded-md overflow-hidden border border-gray-600 text-xs">
|
||||||
|
<button class="flex-1 py-1.5 bg-gray-700 text-gray-400 border-r border-gray-600">Norm</button>
|
||||||
|
<button class="flex-1 py-1.5 btn-selected text-white border-r border-gray-600">IF In</button>
|
||||||
|
<button class="flex-1 py-1.5 bg-gray-700 text-gray-400">Corn</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hold as compact chips -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-1">Hold</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button class="w-8 h-8 text-[10px] font-bold rounded-md border border-gray-600 bg-gray-700 text-gray-500 opacity-40" disabled>1B</button>
|
||||||
|
<button class="w-8 h-8 text-[10px] font-bold rounded-md border border-gray-600 bg-gray-700 text-gray-500 opacity-40" disabled>2B</button>
|
||||||
|
<button class="w-8 h-8 text-[10px] font-bold rounded-md border border-blue-500 btn-selected text-white">3B</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DESIGN C: Toolbar Style -->
|
||||||
|
<div class="mockup-card">
|
||||||
|
<h3 class="text-sm font-semibold text-emerald-400 mb-3">Design C — Toolbar Strip</h3>
|
||||||
|
<div class="bg-gray-800 rounded-xl shadow-lg overflow-hidden">
|
||||||
|
<!-- Single toolbar bar -->
|
||||||
|
<div class="flex items-center gap-3 px-3 py-2.5 border-b border-gray-700">
|
||||||
|
<span class="text-sm font-bold text-white flex-shrink-0">🛡️</span>
|
||||||
|
|
||||||
|
<!-- Infield segment -->
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-[10px] text-gray-500 font-medium">IF:</span>
|
||||||
|
<div class="flex rounded-md overflow-hidden border border-gray-600">
|
||||||
|
<button class="px-2 py-1 text-[11px] bg-gray-700 text-gray-400 border-r border-gray-600">Norm</button>
|
||||||
|
<button class="px-2 py-1 text-[11px] btn-selected text-white border-r border-gray-600">In</button>
|
||||||
|
<button class="px-2 py-1 text-[11px] bg-gray-700 text-gray-400">Corn</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="w-px h-5 bg-gray-700"></div>
|
||||||
|
|
||||||
|
<!-- Hold toggles -->
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-[10px] text-gray-500 font-medium">Hold:</span>
|
||||||
|
<button class="w-6 h-6 text-[10px] font-bold rounded border border-blue-500 btn-selected text-white">3</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Spacer + confirm -->
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button class="px-3 py-1 text-xs font-semibold bg-green-600 text-white rounded-md">✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- SCENARIO: FULL OPTIONS (R1+R3, walk-off) -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="border-t border-gray-700 pt-6">
|
||||||
|
<h2 class="text-lg font-bold text-yellow-400 mb-1">Scenario: Full Options (R1 + R3, Walk-off)</h2>
|
||||||
|
<p class="text-xs text-gray-400 mb-6">All infield options + shallow outfield + hold runners on 1st and 3rd.</p>
|
||||||
|
|
||||||
|
<!-- DESIGN A: Inline Segmented Control -->
|
||||||
|
<div class="mockup-card mb-8">
|
||||||
|
<h3 class="text-sm font-semibold text-emerald-400 mb-3">Design A — Inline Segmented Controls</h3>
|
||||||
|
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-base font-bold text-white flex items-center gap-2">
|
||||||
|
🛡️ Defense
|
||||||
|
</h3>
|
||||||
|
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors">
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Infield -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
|
||||||
|
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white border-r border-gray-600">Normal</button>
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium bg-gray-700 text-gray-300 hover:bg-gray-600 border-r border-gray-600">IF In</button>
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium bg-gray-700 text-gray-300 hover:bg-gray-600">Corners</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Outfield -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Outfield</span>
|
||||||
|
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white border-r border-gray-600">Normal</button>
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium bg-gray-700 text-gray-300 hover:bg-gray-600">Shallow</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hold runners -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">1B</button>
|
||||||
|
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-gray-600 bg-gray-700 text-gray-400 opacity-40 cursor-not-allowed" disabled>2B</button>
|
||||||
|
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">3B</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DESIGN B: Ultra Compact -->
|
||||||
|
<div class="mockup-card mb-8">
|
||||||
|
<h3 class="text-sm font-semibold text-emerald-400 mb-3">Design B — Ultra Compact</h3>
|
||||||
|
<div class="bg-gray-800 rounded-xl shadow-lg p-3 space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-bold text-white">🛡️ Defense</span>
|
||||||
|
<button class="px-3 py-1 text-xs font-semibold bg-green-600 text-white rounded-md">Confirm</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<!-- Infield -->
|
||||||
|
<div>
|
||||||
|
<div class="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-1">Infield</div>
|
||||||
|
<div class="flex rounded-md overflow-hidden border border-gray-600 text-xs">
|
||||||
|
<button class="flex-1 py-1.5 btn-selected text-white border-r border-gray-600">Norm</button>
|
||||||
|
<button class="flex-1 py-1.5 bg-gray-700 text-gray-400 border-r border-gray-600">In</button>
|
||||||
|
<button class="flex-1 py-1.5 bg-gray-700 text-gray-400">Corn</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Outfield -->
|
||||||
|
<div>
|
||||||
|
<div class="text-[10px] font-medium text-gray-500 uppercase tracking-wider mb-1">Outfield</div>
|
||||||
|
<div class="flex rounded-md overflow-hidden border border-gray-600 text-xs">
|
||||||
|
<button class="flex-1 py-1.5 btn-selected text-white border-r border-gray-600">Norm</button>
|
||||||
|
<button class="flex-1 py-1.5 bg-gray-700 text-gray-400">Shallow</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hold runners - full width row below -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="text-[10px] font-medium text-gray-500 uppercase tracking-wider">Hold</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button class="w-8 h-7 text-[10px] font-bold rounded-md border border-blue-500 btn-selected text-white">1B</button>
|
||||||
|
<button class="w-8 h-7 text-[10px] font-bold rounded-md border border-gray-600 bg-gray-700 text-gray-500 opacity-40" disabled>2B</button>
|
||||||
|
<button class="w-8 h-7 text-[10px] font-bold rounded-md border border-blue-500 btn-selected text-white">3B</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DESIGN C: Toolbar Strip -->
|
||||||
|
<div class="mockup-card">
|
||||||
|
<h3 class="text-sm font-semibold text-emerald-400 mb-3">Design C — Toolbar Strip</h3>
|
||||||
|
<div class="bg-gray-800 rounded-xl shadow-lg overflow-hidden">
|
||||||
|
<!-- Row 1: Infield + Outfield -->
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 border-b border-gray-700">
|
||||||
|
<span class="text-sm font-bold text-white flex-shrink-0">🛡️</span>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-[10px] text-gray-500 font-medium">IF:</span>
|
||||||
|
<div class="flex rounded-md overflow-hidden border border-gray-600">
|
||||||
|
<button class="px-2 py-1 text-[11px] btn-selected text-white border-r border-gray-600">Norm</button>
|
||||||
|
<button class="px-2 py-1 text-[11px] bg-gray-700 text-gray-400 border-r border-gray-600">In</button>
|
||||||
|
<button class="px-2 py-1 text-[11px] bg-gray-700 text-gray-400">Corn</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-px h-5 bg-gray-700"></div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-[10px] text-gray-500 font-medium">OF:</span>
|
||||||
|
<div class="flex rounded-md overflow-hidden border border-gray-600">
|
||||||
|
<button class="px-2 py-1 text-[11px] btn-selected text-white border-r border-gray-600">Norm</button>
|
||||||
|
<button class="px-2 py-1 text-[11px] bg-gray-700 text-gray-400">Shal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-px h-5 bg-gray-700"></div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-[10px] text-gray-500 font-medium">Hold:</span>
|
||||||
|
<button class="w-5 h-5 text-[9px] font-bold rounded border border-blue-500 btn-selected text-white">1</button>
|
||||||
|
<button class="w-5 h-5 text-[9px] font-bold rounded border border-blue-500 btn-selected text-white">3</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button class="px-3 py-1 text-xs font-semibold bg-green-600 text-white rounded-md">✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- SIDE-BY-SIDE SIZE COMPARISON -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="border-t border-gray-700 pt-6">
|
||||||
|
<h2 class="text-lg font-bold text-yellow-400 mb-1">Size Comparison: Current vs Design A</h2>
|
||||||
|
<p class="text-xs text-gray-400 mb-6">Same scenario (R3 only) showing height saved.</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<!-- CURRENT -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-semibold text-red-400 mb-2 text-center">CURRENT (~340px tall)</h3>
|
||||||
|
<div class="bg-gray-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-lg font-bold text-white flex items-center gap-2">
|
||||||
|
<span class="text-xl">🛡️</span> Defensive Setup
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-300 mb-3">Infield Depth</label>
|
||||||
|
<div class="flex flex-col w-full">
|
||||||
|
<button class="w-full py-2.5 text-sm btn-selected text-white rounded-t-lg border border-blue-600">• Normal</button>
|
||||||
|
<button class="w-full py-2.5 text-sm bg-white text-gray-700 border border-gray-300">⬆️ Infield In</button>
|
||||||
|
<button class="w-full py-2.5 text-sm bg-white text-gray-700 border border-gray-300 rounded-b-lg">◀️▶️ Corners In</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-300 mb-3">Outfield Depth</label>
|
||||||
|
<div class="flex">
|
||||||
|
<button class="flex-1 py-2.5 text-sm btn-selected text-white rounded-lg border border-blue-600">• Normal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-700 rounded-lg p-4">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-300 mb-2">Current Setup</h4>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div><span class="text-gray-400">Infield:</span> <span class="text-white">Normal</span></div>
|
||||||
|
<div><span class="text-gray-400">Outfield:</span> <span class="text-white">Normal</span></div>
|
||||||
|
<div class="col-span-2"><span class="text-gray-400">Holding:</span> <span class="text-white">None</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="w-full py-3 text-base font-semibold bg-green-600 text-white rounded-lg">Submit Defensive Setup</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DESIGN A -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-semibold text-emerald-400 mb-2 text-center">DESIGN A (~120px tall)</h3>
|
||||||
|
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-base font-bold text-white flex items-center gap-2">🛡️ Defense</h3>
|
||||||
|
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 text-white rounded-lg">Confirm</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
|
||||||
|
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white border-r border-gray-600">Normal</button>
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium bg-gray-700 text-gray-300 border-r border-gray-600">IF In</button>
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium bg-gray-700 text-gray-300">Corners</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-gray-600 bg-gray-700 text-gray-400 opacity-40" disabled>1B</button>
|
||||||
|
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-gray-600 bg-gray-700 text-gray-400 opacity-40" disabled>2B</button>
|
||||||
|
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">3B</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- RECOMMENDATION -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="border-t border-gray-700 pt-6 pb-12">
|
||||||
|
<h2 class="text-lg font-bold text-white mb-4">Recommendation</h2>
|
||||||
|
<div class="bg-gray-800 rounded-xl p-4 space-y-3 text-sm text-gray-300">
|
||||||
|
<p><strong class="text-emerald-400">Design A (Inline Segmented)</strong> is the best balance of compactness and usability:</p>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-xs text-gray-400">
|
||||||
|
<li>~65% height reduction vs current design</li>
|
||||||
|
<li>All options visible at a glance — no scrolling needed</li>
|
||||||
|
<li>Touch targets still meet 44px minimum on the segmented buttons</li>
|
||||||
|
<li>Labels (Infield/Outfield/Hold) provide clear context without verbose headers</li>
|
||||||
|
<li>Confirm button in the header row saves a full row of vertical space</li>
|
||||||
|
<li>Removes redundant "Current Setup" preview — the buttons <em>are</em> the preview</li>
|
||||||
|
<li>Horizontal layout works well on mobile portrait (400px fits 3 segments comfortably)</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-xs text-gray-500 mt-3"><strong>Design B</strong> is even more compact but abbreviates labels (Norm/In/Corn) which hurts readability.<br>
|
||||||
|
<strong>Design C</strong> is the most compact but gets cramped in the full-options scenario and doesn't scale well.</p>
|
||||||
|
|
||||||
|
<div class="mt-4 p-3 bg-gray-700/50 rounded-lg">
|
||||||
|
<p class="text-xs font-semibold text-yellow-400 mb-1">Auto-hide logic:</p>
|
||||||
|
<p class="text-xs text-gray-400">When <code class="bg-gray-700 px-1 rounded">hasDefensiveChoices</code> is false (no runner on 3rd AND not walk-off), auto-submit defaults and skip to offensive phase. The component never renders.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
257
mockups/turn-indicator-options.html
Normal file
257
mockups/turn-indicator-options.html
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Turn Indicator — Design Options</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
body { background: #111827; }
|
||||||
|
.mockup-card { max-width: 400px; margin: 0 auto; }
|
||||||
|
.btn-selected { background: linear-gradient(to right, #1e40af, #2563eb); }
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
.pulse-dot { animation: pulse-dot 1.5s ease-in-out infinite; }
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
.shimmer-border {
|
||||||
|
background: linear-gradient(90deg, #22c55e, #86efac, #22c55e);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 2s linear infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="text-gray-100 p-4">
|
||||||
|
<div class="max-w-lg mx-auto space-y-12">
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-center text-white mb-2">Turn Indicator — Design Options</h1>
|
||||||
|
<p class="text-sm text-gray-400 text-center mb-8">Alternatives to the big green "Your Defensive Turn" banner. All shown with the compact Defense card below.</p>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- CURRENT: Big green banner (for comparison) -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="border-t border-gray-700 pt-6">
|
||||||
|
<h2 class="text-lg font-bold text-red-400 mb-1">Current Design</h2>
|
||||||
|
<p class="text-xs text-gray-400 mb-4">~80px green banner above the card. Redundant — the card already implies it's your turn.</p>
|
||||||
|
|
||||||
|
<div class="mockup-card space-y-4">
|
||||||
|
<!-- Turn indicator banner -->
|
||||||
|
<div class="rounded-xl shadow-lg p-4 text-center bg-gradient-to-r from-green-600 to-green-700 text-white">
|
||||||
|
<div class="flex items-center justify-center gap-3">
|
||||||
|
<span class="text-3xl">✋</span>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold">Your Defensive Turn</h2>
|
||||||
|
<p class="text-sm opacity-90 mt-0.5">Set your defensive positioning and strategy</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Defense card -->
|
||||||
|
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h3 class="text-base font-bold text-white flex items-center gap-2">🧤 Defense</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
|
||||||
|
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white">Normal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">1B</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 text-white rounded-lg">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- OPTION A: No separate indicator — green left accent border -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="border-t border-gray-700 pt-6">
|
||||||
|
<h2 class="text-lg font-bold text-emerald-400 mb-1">Option A: Green Left Accent</h2>
|
||||||
|
<p class="text-xs text-gray-400 mb-4">No separate banner. A green left border on the card itself signals "action needed". Saves ~84px of vertical space.</p>
|
||||||
|
|
||||||
|
<div class="mockup-card">
|
||||||
|
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3 border-l-4 border-green-500">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h3 class="text-base font-bold text-white flex items-center gap-2">🧤 Defense</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
|
||||||
|
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white">Normal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">1B</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 text-white rounded-lg">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- OPTION B: Pulsing dot + "Your Turn" badge in card header -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="border-t border-gray-700 pt-6">
|
||||||
|
<h2 class="text-lg font-bold text-emerald-400 mb-1">Option B: Pulsing Dot + Badge</h2>
|
||||||
|
<p class="text-xs text-gray-400 mb-4">A small pulsing green dot and "Your Turn" pill badge integrated into the card header. Draws attention without a full banner.</p>
|
||||||
|
|
||||||
|
<div class="mockup-card">
|
||||||
|
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-base font-bold text-white flex items-center gap-2">
|
||||||
|
<span class="relative flex h-2.5 w-2.5">
|
||||||
|
<span class="pulse-dot absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||||
|
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500"></span>
|
||||||
|
</span>
|
||||||
|
🧤 Defense
|
||||||
|
</h3>
|
||||||
|
<span class="px-2 py-0.5 text-[10px] font-semibold rounded-full bg-green-500/20 text-green-400 uppercase tracking-wider">Your Turn</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
|
||||||
|
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white">Normal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">1B</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 text-white rounded-lg">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- OPTION C: Shimmer top border accent -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="border-t border-gray-700 pt-6">
|
||||||
|
<h2 class="text-lg font-bold text-emerald-400 mb-1">Option C: Animated Top Border</h2>
|
||||||
|
<p class="text-xs text-gray-400 mb-4">A thin animated green shimmer along the top edge of the card. Subtle but eye-catching. No extra text.</p>
|
||||||
|
|
||||||
|
<div class="mockup-card">
|
||||||
|
<div class="rounded-xl overflow-hidden shadow-lg">
|
||||||
|
<!-- Shimmer border strip -->
|
||||||
|
<div class="h-1 shimmer-border"></div>
|
||||||
|
<div class="bg-gray-800 p-4 space-y-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h3 class="text-base font-bold text-white flex items-center gap-2">🧤 Defense</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
|
||||||
|
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white">Normal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">1B</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 text-white rounded-lg">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- OPTION D: Accent border + badge (A + B combined) -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="border-t border-gray-700 pt-6">
|
||||||
|
<h2 class="text-lg font-bold text-emerald-400 mb-1">Option D: Left Accent + Badge</h2>
|
||||||
|
<p class="text-xs text-gray-400 mb-4">Combines the green left border with the "Your Turn" badge. Maximum clarity without a separate component.</p>
|
||||||
|
|
||||||
|
<div class="mockup-card">
|
||||||
|
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3 border-l-4 border-green-500">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-base font-bold text-white flex items-center gap-2">🧤 Defense</h3>
|
||||||
|
<span class="px-2 py-0.5 text-[10px] font-semibold rounded-full bg-green-500/20 text-green-400 uppercase tracking-wider">Your Turn</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
|
||||||
|
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white">Normal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">1B</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 text-white rounded-lg">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- OPTION E: Glowing ring -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="border-t border-gray-700 pt-6">
|
||||||
|
<h2 class="text-lg font-bold text-emerald-400 mb-1">Option E: Glowing Ring</h2>
|
||||||
|
<p class="text-xs text-gray-400 mb-4">The entire card gets a subtle green glow/ring. Clearly "active" vs the other cards on screen without any extra text or elements.</p>
|
||||||
|
|
||||||
|
<div class="mockup-card">
|
||||||
|
<div class="bg-gray-800 rounded-xl shadow-lg p-4 space-y-3 ring-2 ring-green-500/60 shadow-green-500/20 shadow-lg">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h3 class="text-base font-bold text-white flex items-center gap-2">🧤 Defense</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Infield</span>
|
||||||
|
<div class="flex flex-1 rounded-lg overflow-hidden border border-gray-600">
|
||||||
|
<button class="flex-1 py-2 text-xs font-medium btn-selected text-white">Normal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-400 w-14 flex-shrink-0">Hold</span>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button class="px-3 py-1.5 text-xs font-medium rounded-full border border-blue-500 btn-selected text-white">1B</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button class="px-4 py-1.5 text-sm font-semibold bg-green-600 text-white rounded-lg">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- COMPARISON: Opponent's turn / Waiting state -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div class="border-t border-gray-700 pt-6 pb-12">
|
||||||
|
<h2 class="text-lg font-bold text-yellow-400 mb-1">Contrast: Opponent's Turn</h2>
|
||||||
|
<p class="text-xs text-gray-400 mb-4">For comparison — what the waiting state looks like when it's NOT your turn. The contrast makes the green treatments above stand out.</p>
|
||||||
|
|
||||||
|
<div class="mockup-card">
|
||||||
|
<div class="bg-gray-800 rounded-xl shadow-lg p-8 text-center opacity-70">
|
||||||
|
<div class="text-4xl mb-3">⏳</div>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-400">Waiting for Opponent</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user