diff --git a/magic8ball/.env.example b/magic8ball/.env.example new file mode 100644 index 0000000..cd33e4e --- /dev/null +++ b/magic8ball/.env.example @@ -0,0 +1,17 @@ +.env.example +# Magic 8 Ball Configuration + +# FastAPI Settings +DEBUG=false +APP_NAME="Magic 8 Ball" +API_HOST=0.0.0.0 +API_PORT=8000 + +# CORS Settings (comma-separated) +CORS_ORIGINS=* + +# Database (optional, for future use) +DATABASE_URL=sqlite:///./magic8ball.db + +# Animation +ANIMATION_DELAY_MS=500 diff --git a/magic8ball/.gitignore b/magic8ball/.gitignore new file mode 100644 index 0000000..979eb03 --- /dev/null +++ b/magic8ball/.gitignore @@ -0,0 +1,32 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.env +.env.local +*.db +.DS_Store +*.log +.vscode/ +.idea/ +node_modules/ +.pytest_cache/ +htmlcov/ +.coverage +dist/ diff --git a/magic8ball/API_DOCUMENTATION.md b/magic8ball/API_DOCUMENTATION.md new file mode 100644 index 0000000..4958ba5 --- /dev/null +++ b/magic8ball/API_DOCUMENTATION.md @@ -0,0 +1,325 @@ +# Magic 8 Ball - API Documentation + +## Base URL +``` +http://localhost:8000 +``` + +## Endpoints + +### 1. Serve Homepage +```http +GET / +``` + +**Response:** HTML file + +--- + +### 2. Ask the Magic 8 Ball +```http +POST /ask +Content-Type: application/json + +{ + "question": "Will I succeed?", + "response_pack": "default" +} +``` + +**Parameters:** +- `question` (string, required): The question to ask (1-500 characters) +- `response_pack` (string, optional): Response pack type + - `default` - Standard responses + - `funny` - Humorous responses + - `serious` - Serious, factual responses + - `motivational` - Encouraging responses + +**Success Response (200):** +```json +{ + "answer": "Yes, definitely", + "answer_type": "positive", + "response_pack": "default", + "timestamp": "2024-01-04T12:34:56.789Z", + "question": "Will I succeed?" +} +``` + +**Answer Types:** +- `positive` - Affirmative answer (40% probability) +- `negative` - Negative answer (40% probability) +- `neutral` - Non-committal answer (20% probability) +- `easter_egg` - Special rare answer (5% probability) + +**Error Response (400):** +```json +{ + "error": "Question cannot be empty", + "status_code": 400, + "timestamp": "2024-01-04T12:34:56.789Z" +} +``` + +--- + +### 3. Get All Themes +```http +GET /themes +``` + +**Response (200):** +```json +{ + "themes": [ + { + "id": "classic", + "name": "Classic", + "description": "Timeless Magic 8 Ball look", + "colors": { + "bg": "#1a1a2e", + "primary": "#0f3460", + "accent": "#e94560", + "text": "#eaeaea", + "secondary": "#16213e" + } + } + ] +} +``` + +**Available Themes:** +1. `classic` - Blue and red classic look +2. `neon` - Cyberpunk neon aesthetic +3. `galaxy` - Cosmic purple and pink +4. `cute` - Pastel yellow and pink +5. `dark` - Dark mode with blue accents +6. `light` - Light mode with blue accents + +--- + +### 4. Get Specific Theme +```http +GET /themes/{theme_id} +``` + +**Example:** +``` +GET /themes/neon +``` + +**Response (200):** +```json +{ + "id": "neon", + "name": "Neon", + "description": "Cyberpunk neon aesthetic", + "colors": { + "bg": "#0a0e27", + "primary": "#00ff88", + "accent": "#ff006e", + "text": "#00ff88", + "secondary": "#00ffff" + } +} +``` + +**Error Response (404):** +```json +{ + "error": "Theme 'invalid' not found", + "status_code": 404, + "timestamp": "2024-01-04T12:34:56.789Z" +} +``` + +--- + +### 5. Get Response Packs +```http +GET /response-packs +``` + +**Response (200):** +```json +{ + "packs": ["default", "funny", "serious", "motivational"], + "counts": { + "default": { + "positive": 15, + "negative": 15, + "neutral": 15, + "easter_eggs": 11 + }, + "funny": { + "positive": 6, + "negative": 6, + "neutral": 6, + "easter_eggs": 4 + } + } +} +``` + +--- + +### 6. Get Achievements +```http +GET /achievements +``` + +**Response (200):** +```json +{ + "achievements": [ + { + "id": "first_question", + "name": "First Question", + "description": "Ask your first question", + "icon": "๐ŸŽฑ" + }, + { + "id": "curious_mind", + "name": "Curious Mind", + "description": "Ask 10 questions", + "icon": "๐Ÿค”" + } + ] +} +``` + +**All Achievements:** +1. `first_question` (๐ŸŽฑ) - Ask 1 question +2. `curious_mind` (๐Ÿค”) - Ask 10 questions +3. `oracle_seeker` (๐Ÿ”ฎ) - Ask 50 questions +4. `master_questioner` (๐Ÿ‘‘) - Ask 100 questions +5. `devoted_believer` (โœจ) - Ask 250 questions +6. `wisdom_collector` (๐ŸŒŸ) - Ask 500 questions +7. `easter_egg_hunter` (๐Ÿฅš) - Find an easter egg + +--- + +### 7. Health Check +```http +GET /health +``` + +**Response (200):** +```json +{ + "status": "healthy", + "timestamp": "2024-01-04T12:34:56.789Z", + "service": "Magic 8 Ball" +} +``` + +--- + +### 8. App Information +```http +GET /info +``` + +**Response (200):** +```json +{ + "name": "Magic 8 Ball", + "version": "1.0.0", + "features": [ + "Multiple response packs", + "6 beautiful themes", + "Easter eggs", + "Smooth animations", + "Responsive design", + "Achievement system", + "Question history", + "Sound effects" + ] +} +``` + +--- + +## Error Codes + +| Code | Meaning | +|------|---------| +| 200 | Success | +| 400 | Bad Request (invalid input) | +| 404 | Not Found | +| 500 | Internal Server Error | + +--- + +## CORS + +All endpoints support CORS. Requests from any origin are allowed. + +--- + +## Rate Limiting + +Not implemented. Future enhancement for production. + +--- + +## WebSocket + +Not implemented. Future enhancement for real-time features. + +--- + +## Example Usage + +### JavaScript (Fetch API) + +```javascript +// Ask a question +const response = await fetch('http://localhost:8000/ask', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + question: 'Will I succeed?', + response_pack: 'motivational' + }) +}); + +const data = await response.json(); +console.log(data.answer); // Output: "You've got this!" +``` + +### Python (Requests) + +```python +import requests + +response = requests.post( + 'http://localhost:8000/ask', + json={ + 'question': 'Is this test working?', + 'response_pack': 'funny' + } +) + +print(response.json()['answer']) # Output: Random funny response +``` + +### cURL + +```bash +curl -X POST http://localhost:8000/ask \ + -H "Content-Type: application/json" \ + -d '{"question": "Will I win?", "response_pack": "default"}' +``` + +--- + +## Notes + +- All timestamps are in ISO 8601 format (UTC) +- Question input is automatically trimmed of whitespace +- Response selection is pseudo-random +- Easter eggs have ~5% occurrence rate +- All responses are cached in memory (no database queries) diff --git a/magic8ball/Dockerfile b/magic8ball/Dockerfile new file mode 100644 index 0000000..0dff254 --- /dev/null +++ b/magic8ball/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY . . + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" + +# Run application +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/magic8ball/README.md b/magic8ball/README.md new file mode 100644 index 0000000..e603df7 --- /dev/null +++ b/magic8ball/README.md @@ -0,0 +1,240 @@ +# โœจ Magic 8 Ball - Production-Ready Web Game + +A beautiful, smooth, and engaging Magic 8 Ball web application built with FastAPI and vanilla JavaScript. Features multiple themes, achievements, history tracking, and delightful animations. + +## ๐ŸŽฎ Features + +### Core Game +- โœ… Smooth shake animation with anticipation delay +- โœ… Balanced answer distribution (positive, negative, neutral, easter eggs) +- โœ… Multiple response packs (default, funny, serious, motivational) +- โœ… Configurable responses (JSON file, easily upgradable to database) +- โœ… Graceful input validation and error handling + +### User Experience +- โœ… 6 Beautiful themes (Classic, Neon, Galaxy, Cute, Dark, Light) +- โœ… Smooth 60 FPS animations (CSS3) +- โœ… Mobile-first responsive design +- โœ… Dark/Light mode support +- โœ… Accessible fonts and high-contrast colors +- โœ… Sound effects toggle with Web Audio API + +### Engagement Features +- โœ… Question history with search +- โœ… Favorite answers marking +- โœ… Achievement system (7 achievements) +- โœ… Daily question counter +- โœ… Total questions statistics +- โœ… Share results functionality +- โœ… Local storage persistence + +### Technical +- โœ… FastAPI async backend +- โœ… Pydantic validation +- โœ… Comprehensive unit tests (25+ tests) +- โœ… Clean architecture (separation of concerns) +- โœ… Proper error handling +- โœ… CORS support +- โœ… Production-ready logging + +## ๐Ÿš€ Quick Start + +### Prerequisites +- Python 3.8+ +- pip or poetry + +### Installation + +```bash +# Clone or navigate to project directory +cd magic8ball + +# Create virtual environment (recommended) +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# Install dependencies +pip install -r requirements.txt + +# Run tests +pytest tests/ -v + +# Start the server +python -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000 +``` + +Visit `http://localhost:8000` in your browser. + +## ๐Ÿ“ Project Structure + +``` +magic8ball/ +โ”œโ”€โ”€ backend/ +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ config.py # Settings & configuration +โ”‚ โ”œโ”€โ”€ magic8ball.py # Core game logic (Magic8Ball class) +โ”‚ โ”œโ”€โ”€ models.py # Pydantic validation models +โ”‚ โ”œโ”€โ”€ main.py # FastAPI application +โ”‚ โ””โ”€โ”€ responses.json # Responses configuration +โ”œโ”€โ”€ frontend/ +โ”‚ โ”œโ”€โ”€ index.html # Single-page app +โ”‚ โ”œโ”€โ”€ styles.css # Themes & animations +โ”‚ โ”œโ”€โ”€ api.js # API client +โ”‚ โ”œโ”€โ”€ game.js # Game state & logic +โ”‚ โ””โ”€โ”€ ui.js # UI interactions +โ”œโ”€โ”€ tests/ +โ”‚ โ””โ”€โ”€ test_backend.py # Comprehensive test suite +โ”œโ”€โ”€ requirements.txt # Python dependencies +โ”œโ”€โ”€ .gitignore +โ””โ”€โ”€ README.md +``` + +## ๐Ÿ”Œ API Endpoints + +### Core Game +- `GET /` - Serve homepage +- `POST /ask` - Ask the Magic 8 Ball + ```json + { + "question": "Will I succeed?", + "response_pack": "default" + } + ``` +- `GET /response-packs` - Get available response packs +- `GET /themes` - Get all themes +- `GET /themes/{theme_id}` - Get specific theme +- `GET /achievements` - Get all achievements + +### System +- `GET /health` - Health check +- `GET /info` - App information + +## ๐ŸŽจ Themes + +- **Classic** - Timeless Magic 8 Ball look (blue/red) +- **Neon** - Cyberpunk aesthetic (neon green/pink) +- **Galaxy** - Cosmic vibes (purple/pink) +- **Cute** - Pastel aesthetic (yellow/pink) +- **Dark** - Easy on eyes dark mode +- **Light** - Clean light mode + +Themes automatically persist to localStorage. + +## ๐Ÿ† Achievements + +1. **First Question** - Ask your first question +2. **Curious Mind** - Ask 10 questions +3. **Oracle Seeker** - Ask 50 questions +4. **Master Questioner** - Ask 100 questions +5. **Devoted Believer** - Ask 250 questions +6. **Wisdom Collector** - Ask 500 questions +7. **Easter Egg Hunter** - Find an Easter egg + +## ๐Ÿงช Testing + +Run the comprehensive test suite: + +```bash +pytest tests/ -v +pytest tests/test_backend.py::TestMagic8BallCore -v +pytest tests/test_backend.py::TestBalancedDistribution -v +``` + +Coverage includes: +- Core game logic (Magic8Ball class) +- Pydantic validation models +- Answer distribution balance +- Response packs +- Edge cases (unicode, special characters, etc.) + +## ๐Ÿ”Š Sound Effects + +The app includes Web Audio API-generated sound effects: +- **Shake Sound** - Low rumble when ball is shaking +- **Reveal Sound** - Ascending tone when answer appears +- **Click Sound** - Subtle feedback on interactions + +Sound can be toggled on/off (preference saved locally). + +## ๐Ÿ’พ Data Persistence + +- **History** - Stored in localStorage (browser) +- **Achievements** - Stored in localStorage +- **Theme preference** - Stored in localStorage +- **Sound setting** - Stored in localStorage +- **Animation speed** - Stored in localStorage + +**Note:** Data persists per browser/device. Ready for backend database upgrade. + +## ๐Ÿš€ Deployment + +### Docker + +```bash +docker build -t magic8ball . +docker run -p 8000:8000 magic8ball +``` + +### Render / Railway / Heroku + +1. Create `.env` file with `DEBUG=false` +2. Push to Git repository +3. Deploy using platform-specific instructions + +The app is cloud-ready with: +- Environment variable support +- Proper logging +- Health check endpoint +- Graceful error handling + +## ๐Ÿ“ฑ Mobile Optimization + +- One-hand friendly layout +- Touch-optimized buttons +- Responsive animations +- Mobile-first CSS design +- Proper viewport meta tags + +## โ™ฟ Accessibility + +- Semantic HTML +- ARIA labels +- High contrast colors +- Respects `prefers-reduced-motion` +- Keyboard navigation support +- Clear focus states + +## ๐Ÿ”ฎ Future Enhancements + +- [ ] User accounts with login +- [ ] Cloud sync for history & achievements +- [ ] Multiplayer mode +- [ ] Custom response packs upload +- [ ] Analytics dashboard +- [ ] Mobile app (Native) +- [ ] PWA support +- [ ] Multi-language support (i18n) +- [ ] Social media integrations +- [ ] Backend database (PostgreSQL) + +## ๐Ÿ“ Code Quality + +- Clean, readable code +- Comprehensive docstrings +- Type hints throughout +- Separation of concerns +- Production-ready error handling +- Proper logging +- Extensible architecture + +## ๐Ÿ“„ License + +MIT License - Feel free to use for personal or commercial projects. + +## ๐Ÿ‘จโ€๐Ÿ’ป Author + +Built with โœจ magic โœจ as a demonstration of production-ready game development principles. + +--- + +**Questions?** Ask the Magic 8 Ball! ๐ŸŽฑ diff --git a/magic8ball/backend/__init__.py b/magic8ball/backend/__init__.py new file mode 100644 index 0000000..21031a8 --- /dev/null +++ b/magic8ball/backend/__init__.py @@ -0,0 +1,2 @@ +"""Magic 8 Ball Backend Package""" +__version__ = "1.0.0" diff --git a/magic8ball/backend/config.py b/magic8ball/backend/config.py new file mode 100644 index 0000000..772bf8d --- /dev/null +++ b/magic8ball/backend/config.py @@ -0,0 +1,21 @@ +"""Configuration for Magic 8 Ball Backend""" +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + """Application settings""" + app_name: str = "Magic 8 Ball" + debug: bool = False + api_host: str = "0.0.0.0" + api_port: int = 8000 + cors_origins: list = ["*"] + database_url: str = "sqlite:///./magic8ball.db" + animation_delay_ms: int = 500 + + class Config: + env_file = ".env" + case_sensitive = False + + +settings = Settings() diff --git a/magic8ball/backend/magic8ball.py b/magic8ball/backend/magic8ball.py new file mode 100644 index 0000000..8fe0f28 --- /dev/null +++ b/magic8ball/backend/magic8ball.py @@ -0,0 +1,179 @@ +"""Core Magic 8 Ball game logic""" +import json +import random +from pathlib import Path +from datetime import datetime, date +from typing import Dict, List, Optional, Tuple +from enum import Enum + + +class AnswerType(str, Enum): + """Types of answers""" + POSITIVE = "positive" + NEGATIVE = "negative" + NEUTRAL = "neutral" + EASTER_EGG = "easter_egg" + + +class Magic8Ball: + """Core Magic 8 Ball game engine""" + + def __init__(self, responses_file: Optional[str] = None): + """ + Initialize Magic 8 Ball + + Args: + responses_file: Path to JSON file with responses. If None, uses default location. + """ + self.responses: Dict[str, List[str]] = {} + self.response_packs: Dict[str, Dict[str, List[str]]] = {} + self._load_responses(responses_file) + self._load_response_packs() + self.easter_egg_chance = 0.05 # 5% chance of easter egg + + def _load_responses(self, responses_file: Optional[str] = None) -> None: + """Load responses from JSON file""" + if responses_file is None: + responses_file = Path(__file__).parent / "responses.json" + else: + responses_file = Path(responses_file) + + with open(responses_file, 'r', encoding='utf-8') as f: + data = json.load(f) + self.responses = { + AnswerType.POSITIVE: data.get("positive", []), + AnswerType.NEGATIVE: data.get("negative", []), + AnswerType.NEUTRAL: data.get("neutral", []), + AnswerType.EASTER_EGG: data.get("easter_eggs", []) + } + + def _load_response_packs(self) -> None: + """Load response packs (funny, serious, motivational, etc.)""" + self.response_packs = { + "default": self.responses, + "funny": { + AnswerType.POSITIVE: [ + "Heck yeah!", "You betcha!", "Duh, obviously", + "That's what I'm talking about!", "You're gonna crush it!", + "100% certified yes" + ], + AnswerType.NEGATIVE: [ + "Nope nope nope", "Not happening, buddy", + "In your dreams", "Bro...", "Yeah, no", + "Absolutely not gonna happen" + ], + AnswerType.NEUTRAL: [ + "Maybe? ๐Ÿคท", "Ask again later, coward", + "I'm thinking...", "Flip a coin, I'm tired", + "Your guess is as good as mine", "42" + ], + AnswerType.EASTER_EGG: [ + "The answer is yes but also no", "Plot twist: it's complicated", + "This is awkward...", "Error 404: Answer not found" + ] + }, + "serious": { + AnswerType.POSITIVE: [ + "Affirmative", "The data supports this", + "Logically sound", "All indicators suggest yes", + "Correct", "This is the way" + ], + AnswerType.NEGATIVE: [ + "Negative", "Unlikely based on current conditions", + "Statistically improbable", "The evidence suggests otherwise", + "That's a hard no", "Incorrect" + ], + AnswerType.NEUTRAL: [ + "Inconclusive", "Requires further analysis", + "Insufficient data", "Status: unknown", + "Variables are unclear", "Need more information" + ], + AnswerType.EASTER_EGG: [ + "The universe remains silent", + "Forces of nature are indifferent", + "Quantum uncertainty principle applies" + ] + }, + "motivational": { + AnswerType.POSITIVE: [ + "You've got this!", "Absolutely, believe in yourself", + "Go for it!", "Your dreams are valid", + "Success is within reach", + "You're capable of amazing things" + ], + AnswerType.NEGATIVE: [ + "Not now, but don't give up", + "Keep trying, the universe has timing", + "This setback is growth in disguise", + "Not yet, but you will get there", + "Learn from this and grow", + "Patience, your time will come" + ], + AnswerType.NEUTRAL: [ + "The power is in your hands", + "Only you know what's best for you", + "Trust your intuition", "Listen to your heart", + "You have the answers within", + "Follow what feels right" + ], + AnswerType.EASTER_EGG: [ + "You are stronger than you think", + "Every question brings you closer to wisdom", + "The fact that you're asking means you care" + ] + } + } + + def ask(self, question: str, response_pack: str = "default") -> Tuple[str, AnswerType]: + """ + Ask the Magic 8 Ball a question + + Args: + question: The question to ask + response_pack: Which response pack to use + + Returns: + Tuple of (answer_text, answer_type) + + Raises: + ValueError: If question is empty or response_pack is invalid + """ + if not question or not question.strip(): + raise ValueError("Question cannot be empty") + + if response_pack not in self.response_packs: + raise ValueError(f"Unknown response pack: {response_pack}") + + # Determine if this is an easter egg + if random.random() < self.easter_egg_chance: + answer_type = AnswerType.EASTER_EGG + else: + # 40% positive, 40% negative, 20% neutral + answer_type = random.choices( + [AnswerType.POSITIVE, AnswerType.NEGATIVE, AnswerType.NEUTRAL], + weights=[40, 40, 20], + k=1 + )[0] + + pack = self.response_packs[response_pack] + answers = pack[answer_type] + answer = random.choice(answers) + + return answer, answer_type + + def get_available_packs(self) -> List[str]: + """Get list of available response packs""" + return list(self.response_packs.keys()) + + def get_response_count(self, response_pack: str = "default") -> Dict[str, int]: + """Get count of responses by type""" + if response_pack not in self.response_packs: + raise ValueError(f"Unknown response pack: {response_pack}") + + pack = self.response_packs[response_pack] + return { + "positive": len(pack[AnswerType.POSITIVE]), + "negative": len(pack[AnswerType.NEGATIVE]), + "neutral": len(pack[AnswerType.NEUTRAL]), + "easter_eggs": len(pack[AnswerType.EASTER_EGG]) + } diff --git a/magic8ball/backend/main.py b/magic8ball/backend/main.py new file mode 100644 index 0000000..2e945a6 --- /dev/null +++ b/magic8ball/backend/main.py @@ -0,0 +1,326 @@ +"""FastAPI main application for Magic 8 Ball""" +from fastapi import FastAPI, HTTPException, status +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from fastapi.middleware.cors import CORSMiddleware +from pathlib import Path +import logging +from datetime import datetime + +from .config import settings +from .models import ( + QuestionRequest, AnswerResponse, ErrorResponse, + ThemeInfo, UserSettings, Achievement +) +from .magic8ball import Magic8Ball, AnswerType + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Initialize FastAPI app +app = FastAPI( + title=settings.app_name, + description="A fun Magic 8 Ball web game with smooth animations", + version="1.0.0" +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Mount static files (CSS, JS) +frontend_static_path = Path(__file__).parent.parent / "frontend" +if frontend_static_path.exists(): + app.mount("/static", StaticFiles(directory=str(frontend_static_path)), name="static") + +# Initialize Magic 8 Ball engine +magic8ball = Magic8Ball() + +# Define themes +THEMES = { + "classic": ThemeInfo( + id="classic", + name="Classic", + description="Timeless Magic 8 Ball look", + colors={ + "bg": "#1a1a2e", + "primary": "#0f3460", + "accent": "#e94560", + "text": "#eaeaea", + "secondary": "#16213e" + } + ), + "neon": ThemeInfo( + id="neon", + name="Neon", + description="Cyberpunk neon aesthetic", + colors={ + "bg": "#0a0e27", + "primary": "#00ff88", + "accent": "#ff006e", + "text": "#00ff88", + "secondary": "#00ffff" + } + ), + "galaxy": ThemeInfo( + id="galaxy", + name="Galaxy", + description="Cosmic galaxy vibes", + colors={ + "bg": "#0b0014", + "primary": "#7209b7", + "accent": "#f72585", + "text": "#e0aaff", + "secondary": "#5a189a" + } + ), + "cute": ThemeInfo( + id="cute", + name="Cute", + description="Pastel cute theme", + colors={ + "bg": "#fef5e7", + "primary": "#f8b739", + "accent": "#ff85a2", + "text": "#333333", + "secondary": "#fcc5d8" + } + ), + "dark": ThemeInfo( + id="dark", + name="Dark Mode", + description="Easy on the eyes dark mode", + colors={ + "bg": "#121212", + "primary": "#1e88e5", + "accent": "#1e88e5", + "text": "#ffffff", + "secondary": "#1f1f1f" + } + ), + "light": ThemeInfo( + id="light", + name="Light Mode", + description="Clean light mode", + colors={ + "bg": "#ffffff", + "primary": "#1976d2", + "accent": "#d32f2f", + "text": "#000000", + "secondary": "#f5f5f5" + } + ) +} + +# Define achievements +ACHIEVEMENTS = { + "first_question": Achievement( + id="first_question", + name="First Question", + description="Ask your first question", + icon="๐ŸŽฑ" + ), + "curious_mind": Achievement( + id="curious_mind", + name="Curious Mind", + description="Ask 10 questions", + icon="๐Ÿค”" + ), + "oracle_seeker": Achievement( + id="oracle_seeker", + name="Oracle Seeker", + description="Ask 50 questions", + icon="๐Ÿ”ฎ" + ), + "master_questioner": Achievement( + id="master_questioner", + name="Master Questioner", + description="Ask 100 questions", + icon="๐Ÿ‘‘" + ), + "devoted_believer": Achievement( + id="devoted_believer", + name="Devoted Believer", + description="Ask 250 questions", + icon="โœจ" + ), + "wisdom_collector": Achievement( + id="wisdom_collector", + name="Wisdom Collector", + description="Ask 500 questions", + icon="๐ŸŒŸ" + ), + "easter_egg_hunter": Achievement( + id="easter_egg_hunter", + name="Easter Egg Hunter", + description="Find an Easter egg", + icon="๐Ÿฅš" + ) +} + + +# ============================================================================ +# API ENDPOINTS +# ============================================================================ + +@app.get("/", tags=["Root"]) +async def serve_homepage(): + """Serve the main HTML page""" + frontend_path = Path(__file__).parent.parent / "frontend" / "index.html" + if frontend_path.exists(): + return FileResponse(frontend_path) + return {"message": "Magic 8 Ball API running! Frontend files not found."} + + +@app.post("/ask", response_model=AnswerResponse, tags=["Game"]) +async def ask_magic8ball(request: QuestionRequest): + """ + Ask the Magic 8 Ball a question + + Returns a random answer based on the question and response pack. + """ + try: + answer, answer_type = magic8ball.ask( + request.question, + request.response_pack + ) + + logger.info(f"Question: {request.question[:50]}... -> {answer}") + + return AnswerResponse( + answer=answer, + answer_type=answer_type.value, + response_pack=request.response_pack, + timestamp=datetime.now(), + question=request.question + ) + except ValueError as e: + logger.error(f"Validation error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + +@app.get("/themes", tags=["Themes"]) +async def get_themes(): + """Get all available themes""" + return { + "themes": [ + { + "id": theme.id, + "name": theme.name, + "description": theme.description, + "colors": theme.colors + } + for theme in THEMES.values() + ] + } + + +@app.get("/themes/{theme_id}", response_model=ThemeInfo, tags=["Themes"]) +async def get_theme(theme_id: str): + """Get a specific theme by ID""" + if theme_id not in THEMES: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Theme '{theme_id}' not found" + ) + return THEMES[theme_id] + + +@app.get("/response-packs", tags=["Game"]) +async def get_response_packs(): + """Get available response packs""" + packs = magic8ball.get_available_packs() + return { + "packs": packs, + "counts": { + pack: magic8ball.get_response_count(pack) + for pack in packs + } + } + + +@app.get("/achievements", tags=["Achievements"]) +async def get_achievements(): + """Get all available achievements""" + return { + "achievements": [ + { + "id": ach.id, + "name": ach.name, + "description": ach.description, + "icon": ach.icon + } + for ach in ACHIEVEMENTS.values() + ] + } + + +@app.get("/health", tags=["System"]) +async def health_check(): + """Health check endpoint""" + return { + "status": "healthy", + "timestamp": datetime.now(), + "service": settings.app_name + } + + +@app.get("/info", tags=["System"]) +async def app_info(): + """Get application information""" + return { + "name": settings.app_name, + "version": "1.0.0", + "features": [ + "Multiple response packs", + "6 beautiful themes", + "Easter eggs", + "Smooth animations", + "Responsive design", + "Achievement system", + "Question history", + "Sound effects" + ] + } + + +# ============================================================================ +# ERROR HANDLERS +# ============================================================================ + +@app.exception_handler(ValueError) +async def value_error_handler(request, exc): + """Handle ValueError exceptions""" + return ErrorResponse( + error=str(exc), + status_code=400 + ) + + +@app.exception_handler(Exception) +async def general_exception_handler(request, exc): + """Handle all other exceptions""" + logger.error(f"Unhandled exception: {str(exc)}", exc_info=True) + return ErrorResponse( + error="An unexpected error occurred", + status_code=500 + ) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "backend.main:app", + host=settings.api_host, + port=settings.api_port, + reload=settings.debug + ) diff --git a/magic8ball/backend/models.py b/magic8ball/backend/models.py new file mode 100644 index 0000000..fd5ed1e --- /dev/null +++ b/magic8ball/backend/models.py @@ -0,0 +1,69 @@ +"""Pydantic models for request/response validation""" +from pydantic import BaseModel, Field, field_validator +from typing import Optional, List +from datetime import datetime + + +class QuestionRequest(BaseModel): + """Request model for asking a question""" + question: str = Field(..., min_length=1, max_length=500, description="The question to ask") + response_pack: Optional[str] = Field("default", description="Response pack: default, funny, serious, motivational") + + @field_validator('question') + @classmethod + def question_not_empty(cls, v: str) -> str: + """Validate question is not just whitespace""" + if not v.strip(): + raise ValueError('Question cannot be empty or just whitespace') + return v.strip() + + +class AnswerResponse(BaseModel): + """Response model for answer""" + answer: str = Field(..., description="The Magic 8 Ball answer") + answer_type: str = Field(..., description="Type: positive, negative, neutral, easter_egg") + response_pack: str = Field(..., description="Response pack used") + timestamp: datetime = Field(default_factory=datetime.now) + question: Optional[str] = Field(None, description="Echo of the question") + + +class HistoryEntry(BaseModel): + """A single history entry""" + id: int + question: str + answer: str + answer_type: str + timestamp: datetime + is_favorite: bool = False + + +class Achievement(BaseModel): + """Achievement model""" + id: str = Field(..., description="Achievement ID") + name: str = Field(..., description="Achievement name") + description: str = Field(..., description="Achievement description") + icon: str = Field(..., description="Achievement emoji icon") + unlocked: bool = False + unlocked_at: Optional[datetime] = None + + +class UserSettings(BaseModel): + """User settings""" + theme: str = "classic" + sound_enabled: bool = True + animation_speed: float = 1.0 + + +class ThemeInfo(BaseModel): + """Theme information""" + id: str + name: str + description: str + colors: dict + + +class ErrorResponse(BaseModel): + """Error response""" + error: str = Field(..., description="Error message") + status_code: int = Field(..., description="HTTP status code") + timestamp: datetime = Field(default_factory=datetime.now) diff --git a/magic8ball/backend/responses.json b/magic8ball/backend/responses.json new file mode 100644 index 0000000..6266557 --- /dev/null +++ b/magic8ball/backend/responses.json @@ -0,0 +1,126 @@ +{ + "positive": [ + "It is certain", + "It is decidedly so", + "Without a doubt", + "Yes, definitely", + "You may rely on it", + "As I see it, yes", + "Most likely", + "Outlook good", + "Yes", + "Signs point to yes", + "Absolutely", + "All signs point favorably", + "The probability is high", + "Chances look good", + "Very favorable odds" + ], + "negative": [ + "Don't count on it", + "My reply is no", + "My sources say no", + "Outlook not so good", + "Very doubtful", + "Absolutely not", + "Not in this lifetime", + "Dream on", + "No way", + "Never happening", + "Slim to none", + "Keep wishing", + "The odds are against you", + "Unlikely at best", + "Don't bet on it" + ], + "neutral": [ + "Reply hazy, try again", + "Ask again later", + "Better not tell you now", + "Cannot predict now", + "Concentrate and ask again", + "The stars are unclear", + "Maybe, maybe not", + "Could go either way", + "Outlook is unclear", + "Time will tell", + "Ask me again tomorrow", + "Too many variables", + "Depends on the circumstances", + "Perhaps, if conditions align", + "The outcome remains uncertain" + ], + "funny": [ + "Lol, no way ๐Ÿ˜‚", + "Ask again when you're not so silly ๐Ÿคช", + "My sources are laughing right now ๐Ÿ˜†", + "That's funny, but no ๐ŸŽญ", + "The spirits think you're hilarious ๐Ÿ˜", + "My magic says: not a chance buddy ๐Ÿ˜…", + "Even the universe has jokes ๐ŸŒ", + "Did you really just ask that? ๐Ÿ˜ฌ", + "Spoiler alert: nope ๐ŸŽฌ", + "The cosmos is chuckling ๐ŸŒŒ", + "My magic is speechless ๐Ÿค", + "Plot twist: impossible ๐ŸŽช", + "The odds? Hilariously bad ๐Ÿ˜†", + "Nice try, friend ๐Ÿ˜‰", + "Absolutely ridiculous ๐Ÿคฃ" + ], + "serious": [ + "The evidence suggests no", + "Statistically unlikely", + "Logic indicates otherwise", + "Probability is low", + "The data shows otherwise", + "Based on current conditions, unlikely", + "Rational analysis says no", + "The metrics are unfavorable", + "Evidence points to rejection", + "Calculations show negative outcome", + "The situation is complex", + "Multiple factors suggest caution", + "Current variables indicate rejection", + "Technical analysis: unlikely", + "The circumstances are unfavorable" + ], + "motivational": [ + "You have the power within you ๐Ÿ’ช", + "Believe and you shall achieve ๐ŸŒŸ", + "Your determination will succeed โœจ", + "The universe supports your dreams ๐Ÿš€", + "Your effort will be rewarded ๐Ÿ†", + "Keep pushing, victory awaits ๐ŸŽฏ", + "Your strength is limitless ๐Ÿ’ซ", + "Success is written in your destiny ๐Ÿ“–", + "You are capable of greatness ๐Ÿ‘‘", + "The magic within you is strong ๐Ÿ”ฅ", + "Your path leads to triumph ๐Ÿ›ค๏ธ", + "Greatness is calling you ๐Ÿ“ฃ", + "Your vision will manifest ๐ŸŽจ", + "You have what it takes ๐Ÿ’Ž", + "The future is yours to create ๐ŸŒˆ" + ], + "easter_eggs": [ + "๐ŸŽฑ *Magic 8 Ball glows mysteriously* โœจ", + "The universe has a plan ๐ŸŒŒ", + "Only you can make this true ๐Ÿ”ฎ", + "The universe is listening ๐Ÿ‘‚", + "Plot twist incoming ๐ŸŽฌ", + "You've got more power than you think ๐Ÿ’ซ", + "The answer already exists within you ๐Ÿง ", + "Trust your gut ๐Ÿ™", + "The magic is in the asking โœจ", + "Something unexpected is coming ๐ŸŽ", + "Your belief makes it real ๐ŸŒŸ", + "The power is yours ๐Ÿ’ช", + "๐ŸŽฒ Jackpot! Lady Luck is with you today", + "โœจ The stars have aligned in your favor", + "๐ŸŒ™ The moon whispers ancient wisdom to you", + "โšก Lightning strikes! This is your moment!", + "๐Ÿ”ฎ The crystal ball reveals: FORTUNE! ๐Ÿ’ฐ", + "๐ŸŒŠ The cosmic waves carry blessings to you", + "โ˜„๏ธ A meteor of luck just hit your timeline", + "๐ŸŽช The universe throws confetti at you ๐ŸŽ‰" + ] +} diff --git a/magic8ball/docker-compose.yml b/magic8ball/docker-compose.yml new file mode 100644 index 0000000..0bbbce7 --- /dev/null +++ b/magic8ball/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' + +services: + magic8ball: + build: . + container_name: magic8ball + ports: + - "8000:8000" + environment: + - DEBUG=false + - API_HOST=0.0.0.0 + - API_PORT=8000 + volumes: + - ./backend:/app/backend + - ./frontend:/app/frontend + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s diff --git a/magic8ball/frontend/api.js b/magic8ball/frontend/api.js new file mode 100644 index 0000000..5f3b0b8 --- /dev/null +++ b/magic8ball/frontend/api.js @@ -0,0 +1,87 @@ +/** + * API Module - Handles all communication with the backend + */ + +class MagicBallAPI { + constructor(baseUrl = '') { + this.baseUrl = baseUrl || this.getBaseUrl(); + this.timeout = 10000; + } + + getBaseUrl() { + const protocol = window.location.protocol; + const hostname = window.location.hostname; + const port = window.location.port; + + if (hostname === 'localhost' || hostname === '127.0.0.1') { + return `${protocol}//${hostname}:8000`; + } + + return `${protocol}//${hostname}${port ? ':' + port : ''}`; + } + + async request(endpoint, options = {}) { + const url = `${this.baseUrl}${endpoint}`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || `HTTP ${response.status}`); + } + + return await response.json(); + } catch (error) { + clearTimeout(timeoutId); + if (error.name === 'AbortError') { + throw new Error('Request timeout - please try again'); + } + throw error; + } + } + + async ask(question, responsePack = 'default') { + return this.request('/ask', { + method: 'POST', + body: JSON.stringify({ question, response_pack: responsePack }), + }); + } + + async getThemes() { + return this.request('/themes'); + } + + async getTheme(themeId) { + return this.request(`/themes/${themeId}`); + } + + async getResponsePacks() { + return this.request('/response-packs'); + } + + async getAchievements() { + return this.request('/achievements'); + } + + async healthCheck() { + return this.request('/health'); + } + + async getAppInfo() { + return this.request('/info'); + } +} + +const api = new MagicBallAPI(); diff --git a/magic8ball/frontend/game.js b/magic8ball/frontend/game.js new file mode 100644 index 0000000..3baa371 --- /dev/null +++ b/magic8ball/frontend/game.js @@ -0,0 +1,282 @@ +/** + * Game Module - Core game logic and state management + */ + +class MagicBallGame { + constructor() { + this.isAsking = false; + this.currentAnswer = null; + this.animationSpeed = 1; + this.soundEnabled = true; + this.currentTheme = 'classic'; + this.history = []; + this.achievements = []; + this.allAchievements = []; + this.streak = 0; + this.combo = 0; + this.bestCombo = 0; + this.lastAnswerType = null; + this.lastAskTime = null; + + this.init(); + } + + init() { + this.loadFromLocalStorage(); + } + + saveToLocalStorage() { + localStorage.setItem('magic8ball_history', JSON.stringify(this.history)); + localStorage.setItem('magic8ball_achievements', JSON.stringify(this.achievements)); + localStorage.setItem('magic8ball_theme', this.currentTheme); + localStorage.setItem('magic8ball_sound', JSON.stringify(this.soundEnabled)); + localStorage.setItem('magic8ball_animation_speed', JSON.stringify(this.animationSpeed)); + localStorage.setItem('magic8ball_streak', JSON.stringify(this.streak)); + localStorage.setItem('magic8ball_best_combo', JSON.stringify(this.bestCombo)); + } + + loadFromLocalStorage() { + try { + const saved_history = localStorage.getItem('magic8ball_history'); + this.history = saved_history ? JSON.parse(saved_history) : []; + + const saved_achievements = localStorage.getItem('magic8ball_achievements'); + this.achievements = saved_achievements ? JSON.parse(saved_achievements) : []; + + const saved_theme = localStorage.getItem('magic8ball_theme'); + this.currentTheme = saved_theme || 'classic'; + + const saved_sound = localStorage.getItem('magic8ball_sound'); + this.soundEnabled = saved_sound !== null ? JSON.parse(saved_sound) : true; + + const saved_speed = localStorage.getItem('magic8ball_animation_speed'); + this.animationSpeed = saved_speed ? JSON.parse(saved_speed) : 1; + + const saved_streak = localStorage.getItem('magic8ball_streak'); + this.streak = saved_streak ? JSON.parse(saved_streak) : 0; + + const saved_best_combo = localStorage.getItem('magic8ball_best_combo'); + this.bestCombo = saved_best_combo ? JSON.parse(saved_best_combo) : 0; + + // Check if streak should be reset (new day) + this.checkStreakReset(); + } catch (error) { + console.error('Failed to load game state:', error); + } + } + + checkStreakReset() { + const lastPlay = localStorage.getItem('magic8ball_last_play_date'); + const today = new Date().toDateString(); + + if (lastPlay && lastPlay !== today) { + // Different day - reset streak if no questions today yet + const todayCount = this.getDailyCount(); + if (todayCount === 0) { + this.streak = 0; + } + } + + localStorage.setItem('magic8ball_last_play_date', today); + } + + addToHistory(question, answer, answerType) { + const entry = { + question, + answer, + answer_type: answerType, + timestamp: new Date().toISOString(), + is_favorite: false, + id: Date.now(), + }; + + this.history.unshift(entry); + + if (this.history.length > 100) { + this.history = this.history.slice(0, 100); + } + + // Update streak - increment each question + this.streak += 1; + + // Update combo - increment for same type in a row + if (this.lastAnswerType === answerType) { + this.combo += 1; + if (this.combo > this.bestCombo) { + this.bestCombo = this.combo; + } + } else { + this.combo = 1; + this.lastAnswerType = answerType; + } + + this.saveToLocalStorage(); + this.checkAchievements(); + return entry; + } + + getStreak() { + return this.streak; + } + + getCombo() { + return this.combo; + } + + getBestCombo() { + return this.bestCombo; + } + + getTotalQuestions() { + return this.history.length; + } + + getDailyCount() { + const today = new Date().toDateString(); + return this.history.filter(entry => { + const entryDate = new Date(entry.timestamp).toDateString(); + return entryDate === today; + }).length; + } + + getFavoriteCount() { + return this.history.filter(entry => entry.is_favorite).length; + } + + toggleFavorite(id) { + const entry = this.history.find(e => e.id === id); + if (entry) { + entry.is_favorite = !entry.is_favorite; + this.saveToLocalStorage(); + } + return entry; + } + + clearHistory() { + this.history = []; + this.saveToLocalStorage(); + } + + searchHistory(query) { + const q = query.toLowerCase().trim(); + if (!q) return this.history; + + return this.history.filter(entry => + entry.question.toLowerCase().includes(q) || + entry.answer.toLowerCase().includes(q) + ); + } + + checkAchievements() { + const total = this.getTotalQuestions(); + const easterEggFound = this.history.some(e => e.answer_type === 'easter_egg'); + + const achievements = [ + { id: 'first_question', threshold: 1 }, + { id: 'curious_mind', threshold: 10 }, + { id: 'oracle_seeker', threshold: 50 }, + { id: 'master_questioner', threshold: 100 }, + { id: 'devoted_believer', threshold: 250 }, + { id: 'wisdom_collector', threshold: 500 }, + ]; + + let newAchievement = null; + + for (const ach of achievements) { + if (total >= ach.threshold && !this.achievements.includes(ach.id)) { + this.achievements.push(ach.id); + newAchievement = ach.id; + } + } + + if (easterEggFound && !this.achievements.includes('easter_egg_hunter')) { + this.achievements.push('easter_egg_hunter'); + if (!newAchievement) newAchievement = 'easter_egg_hunter'; + } + + if (newAchievement) { + this.saveToLocalStorage(); + return this.findAchievementById(newAchievement); + } + + return null; + } + + findAchievementById(id) { + return this.allAchievements.find(a => a.id === id); + } + + setTheme(themeId) { + this.currentTheme = themeId; + document.body.className = `theme-${themeId}`; + this.saveToLocalStorage(); + } + + toggleSound() { + this.soundEnabled = !this.soundEnabled; + this.saveToLocalStorage(); + return this.soundEnabled; + } + + setAnimationSpeed(speed) { + this.animationSpeed = Math.max(0.5, Math.min(2, speed)); + this.saveToLocalStorage(); + } + + playSound(type = 'shake') { + if (!this.soundEnabled) return; + + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const now = audioContext.currentTime; + + try { + if (type === 'shake') { + const osc = audioContext.createOscillator(); + const gain = audioContext.createGain(); + osc.connect(gain); + gain.connect(audioContext.destination); + + osc.frequency.setValueAtTime(100, now); + osc.frequency.exponentialRampToValueAtTime(50, now + 0.2); + gain.gain.setValueAtTime(0.3, now); + gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2); + + osc.start(now); + osc.stop(now + 0.2); + } else if (type === 'reveal') { + const osc = audioContext.createOscillator(); + const gain = audioContext.createGain(); + osc.connect(gain); + gain.connect(audioContext.destination); + + osc.frequency.setValueAtTime(300, now); + osc.frequency.exponentialRampToValueAtTime(800, now + 0.3); + gain.gain.setValueAtTime(0.2, now); + gain.gain.exponentialRampToValueAtTime(0.01, now + 0.3); + + osc.start(now); + osc.stop(now + 0.3); + } else if (type === 'click') { + const osc = audioContext.createOscillator(); + const gain = audioContext.createGain(); + osc.connect(gain); + gain.connect(audioContext.destination); + + osc.frequency.setValueAtTime(200, now); + gain.gain.setValueAtTime(0.1, now); + gain.gain.exponentialRampToValueAtTime(0.01, now + 0.05); + + osc.start(now); + osc.stop(now + 0.05); + } + } catch (error) { + console.warn('Audio playback failed:', error); + } + } + + getAnimationDelay() { + return 500 / this.animationSpeed; + } +} + +const game = new MagicBallGame(); diff --git a/magic8ball/frontend/index.html b/magic8ball/frontend/index.html new file mode 100644 index 0000000..8e99afc --- /dev/null +++ b/magic8ball/frontend/index.html @@ -0,0 +1,181 @@ + + + + + + + + Magic 8 Ball - Ask and Discover + + + + +
+ +
+
+

โœจ Magic 8 Ball โœจ

+

Ask the universe for guidance

+
+
+ + + + +
+
+ + +
+ +
+
+
+
+
+
+
Ask a question...
+
+
+
+
+
+
+
+
+
+ + +
+ + +
+ +
+
+ 0/500 +
+
+ + +
+ + + +
+ + +
+
+
Total Questions
+
0
+
+
+
Today's Streak ๐Ÿ”ฅ
+
0
+
+
+
Current Combo โœจ
+
0
+
+
+
Best Combo ๐Ÿ†
+
0
+
+
+ + + +
+ + + + + + + + + + + + + + + +
+ + + + + + + diff --git a/magic8ball/frontend/styles.css b/magic8ball/frontend/styles.css new file mode 100644 index 0000000..9577c4d --- /dev/null +++ b/magic8ball/frontend/styles.css @@ -0,0 +1,1047 @@ +/* ============================================================================ + MAGIC 8 BALL - COMPREHENSIVE STYLES & ANIMATIONS + ============================================================================ */ + +:root { + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --color-primary: #0f3460; + --color-accent: #e94560; + --text-primary: #eaeaea; + --text-secondary: #b0b0b0; + --spacing-xs: 0.5rem; + --spacing-sm: 1rem; + --spacing-md: 1.5rem; + --spacing-lg: 2rem; + --spacing-xl: 3rem; + --transition-fast: 150ms ease-out; + --transition-normal: 300ms ease-out; + --transition-slow: 500ms ease-out; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html { font-size: 16px; scroll-behavior: smooth; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + overflow-x: hidden; +} + +/* THEME SYSTEM */ +body.theme-classic { + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --color-primary: #0f3460; + --color-accent: #e94560; + --text-primary: #eaeaea; + --text-secondary: #b0b0b0; +} + +body.theme-neon { + --bg-primary: #0a0e27; + --bg-secondary: #0f1a36; + --color-primary: #00ff88; + --color-accent: #ff006e; + --text-primary: #00ff88; + --text-secondary: #00ccff; +} + +body.theme-galaxy { + --bg-primary: #0b0014; + --bg-secondary: #2a0845; + --color-primary: #7209b7; + --color-accent: #f72585; + --text-primary: #e0aaff; + --text-secondary: #c896ff; +} + +body.theme-cute { + --bg-primary: #fef5e7; + --bg-secondary: #fdf2e9; + --color-primary: #f8b739; + --color-accent: #ff85a2; + --text-primary: #333333; + --text-secondary: #666666; +} + +body.theme-dark { + --bg-primary: #121212; + --bg-secondary: #1f1f1f; + --color-primary: #1e88e5; + --color-accent: #1e88e5; + --text-primary: #ffffff; + --text-secondary: #b0b0b0; +} + +body.theme-light { + --bg-primary: #ffffff; + --bg-secondary: #f5f5f5; + --color-primary: #1976d2; + --color-accent: #d32f2f; + --text-primary: #000000; + --text-secondary: #666666; +} + +/* ANIMATIONS */ +@keyframes shake { + 0%, 100% { transform: translate(0, 0) rotate(0deg); } + 10% { transform: translate(-5px, -5px) rotate(-2deg); } + 20% { transform: translate(5px, -5px) rotate(2deg); } + 30% { transform: translate(-5px, 5px) rotate(-2deg); } + 40% { transform: translate(5px, 5px) rotate(2deg); } + 50% { transform: translate(-3px, -3px) rotate(-1deg); } + 60% { transform: translate(3px, -3px) rotate(1deg); } + 70% { transform: translate(-3px, 3px) rotate(-1deg); } + 80% { transform: translate(3px, 3px) rotate(1deg); } + 90% { transform: translate(-1px, 0) rotate(0deg); } +} + +@keyframes glow { + 0% { box-shadow: 0 0 10px rgba(233, 69, 96, 0.3), inset 0 0 10px rgba(233, 69, 96, 0.1); } + 50% { box-shadow: 0 0 30px rgba(233, 69, 96, 0.8), inset 0 0 20px rgba(233, 69, 96, 0.3); } + 100% { box-shadow: 0 0 10px rgba(233, 69, 96, 0.3), inset 0 0 10px rgba(233, 69, 96, 0.1); } +} + +@keyframes fadeInAnswer { + 0% { opacity: 0; transform: scale(0.9); } + 100% { opacity: 1; transform: scale(1); } +} + +@keyframes slideInDown { + from { opacity: 0; transform: translateY(-20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideInUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +/* PAGE LAYOUT */ +.page-container { + min-height: 100vh; + display: flex; + flex-direction: column; + background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); +} + +/* HEADER */ +.header { + padding: var(--spacing-md) var(--spacing-sm); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 2px solid var(--color-primary); + animation: slideInDown 0.6s ease-out; +} + +.header-content { + flex: 1; + text-align: center; +} + +.title { + font-size: 2.5rem; + font-weight: 900; + background: linear-gradient(135deg, var(--color-primary), var(--color-accent)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 0.25rem; + text-transform: uppercase; + letter-spacing: 2px; +} + +.subtitle { + font-size: 0.9rem; + color: var(--text-secondary); + font-style: italic; +} + +.header-controls { + display: flex; + gap: var(--spacing-sm); +} + +.icon-btn { + background: transparent; + border: 2px solid var(--color-primary); + color: var(--text-primary); + width: 44px; + height: 44px; + border-radius: 50%; + cursor: pointer; + font-size: 1.3rem; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-normal); +} + +.icon-btn:hover { + background-color: var(--color-primary); + transform: scale(1.1); + box-shadow: 0 0 15px rgba(233, 69, 96, 0.5); +} + +.icon-btn:active { + transform: scale(0.95); +} + +/* MAIN GAME CONTAINER */ +main { + flex: 1; + padding: var(--spacing-xl) var(--spacing-sm); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-lg); +} + +/* MAGIC 8 BALL */ +.magic-ball-wrapper { + position: relative; + width: 100%; + max-width: 400px; + margin-bottom: var(--spacing-lg); +} + +.magic-ball-container { + position: relative; + perspective: 1000px; +} + +.magic-ball { + width: 280px; + height: 280px; + margin: 0 auto; + position: relative; +} + +.ball-outer { + width: 100%; + height: 100%; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, #1a1a1a, #000000); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.8), inset -20px -20px 40px rgba(0, 0, 0, 0.5), inset 10px 10px 20px rgba(255, 255, 255, 0.1); + position: relative; + transition: all var(--transition-normal); +} + +.ball-outer.shaking { + animation: shake 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.ball-outer.glowing { + animation: glow 1s ease-in-out forwards; +} + +.ball-inner { + width: 200px; + height: 200px; + border-radius: 50%; + background: radial-gradient(circle at 25% 25%, #1a1a1a, #000000); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: center; + justify-content: center; + box-shadow: inset 0 4px 10px rgba(0, 0, 0, 0.8); +} + +.answer-window { + text-align: center; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-xs); +} + +.answer-text { + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary); + line-height: 1.4; + opacity: 0; + transition: opacity var(--transition-normal); + padding: var(--spacing-sm); + max-width: 90%; +} + +.answer-text.show { + opacity: 1; + animation: fadeInAnswer 0.5s ease-out forwards; +} + +.answer-type { + font-size: 0.75rem; + color: var(--color-accent); + text-transform: uppercase; + letter-spacing: 1px; + font-weight: 700; + opacity: 0; + transition: opacity var(--transition-normal); +} + +.answer-type.show { + opacity: 1; +} + +.ball-shine { + position: absolute; + width: 80px; + height: 80px; + background: radial-gradient(circle, rgba(255, 255, 255, 0.4), transparent); + border-radius: 50%; + top: 20px; + left: 30px; + pointer-events: none; +} + +.ball-reflection { + width: 280px; + height: 40px; + margin: 0 auto; + background: radial-gradient(ellipse, rgba(233, 69, 96, 0.2), transparent); + border-radius: 50%; + filter: blur(10px); + transform: scaleX(0.8); +} + +/* INPUT SECTION */ +.input-section { + width: 100%; + max-width: 500px; + animation: slideInUp 0.6s ease-out 0.2s both; +} + +.input-label { + display: block; + margin-bottom: var(--spacing-sm); + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 1px; +} + +.question-input { + width: 100%; + padding: var(--spacing-md) var(--spacing-sm); + border: 2px solid var(--color-primary); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 1rem; + font-family: inherit; + transition: all var(--transition-normal); + margin-bottom: var(--spacing-sm); +} + +.question-input::placeholder { + color: var(--text-secondary); +} + +.question-input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.2); +} + +.input-controls { + display: flex; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.response-pack-select { + flex: 1; + padding: var(--spacing-sm); + border: 2px solid var(--color-primary); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 0.95rem; + font-family: inherit; + cursor: pointer; + transition: all var(--transition-normal); +} + +.response-pack-select:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.2); +} + +.char-count { + font-size: 0.8rem; + color: var(--text-secondary); + text-align: right; +} + +/* BUTTONS */ +.button-group { + display: flex; + gap: var(--spacing-md); + width: 100%; + max-width: 500px; + justify-content: center; + flex-wrap: wrap; + animation: slideInUp 0.6s ease-out 0.3s both; +} + +.ask-btn { + flex: 1; + min-width: 200px; + padding: var(--spacing-md) var(--spacing-lg); + background: linear-gradient(135deg, var(--color-primary), var(--color-accent)); + color: white; + border: none; + border-radius: 8px; + font-size: 1.05rem; + font-weight: 700; + cursor: pointer; + transition: all var(--transition-normal); + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + text-transform: uppercase; + letter-spacing: 1px; + box-shadow: 0 10px 30px rgba(233, 69, 96, 0.3); + position: relative; + overflow: hidden; +} + +.ask-btn::before { + content: ""; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.2); + transition: left var(--transition-normal); +} + +.ask-btn:hover::before { + left: 100%; +} + +.ask-btn:hover { + transform: translateY(-2px); + box-shadow: 0 15px 40px rgba(233, 69, 96, 0.5); +} + +.ask-btn:active { + transform: translateY(0); +} + +.ask-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.secondary-btn { + padding: var(--spacing-md) var(--spacing-lg); + background: transparent; + color: var(--text-primary); + border: 2px solid var(--color-primary); + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-normal); + text-transform: uppercase; + letter-spacing: 1px; +} + +.secondary-btn:hover { + background: var(--color-primary); + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(233, 69, 96, 0.3); +} + +.btn-small { + padding: var(--spacing-sm) var(--spacing-md); + background: var(--color-primary); + color: var(--text-primary); + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; + transition: all var(--transition-normal); +} + +.btn-small:hover { + background: var(--color-accent); + transform: scale(1.05); +} + +/* STATS SECTION */ +.stats-section { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--spacing-md); + width: 100%; + max-width: 500px; + margin-top: var(--spacing-lg); + animation: slideInUp 0.6s ease-out 0.4s both; +} + +.stat { + text-align: center; + padding: var(--spacing-md); + background: var(--bg-secondary); + border: 2px solid var(--color-primary); + border-radius: 8px; + transition: all var(--transition-normal); +} + +.stat:hover { + border-color: var(--color-accent); + transform: translateY(-4px); + box-shadow: 0 10px 20px rgba(233, 69, 96, 0.2); +} + +.stat-label { + font-size: 0.8rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: var(--spacing-xs); +} + +.stat-value { + font-size: 2rem; + font-weight: 900; + background: linear-gradient(135deg, var(--color-primary), var(--color-accent)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ACHIEVEMENT TOAST */ +.achievement-toast { + width: 100%; + max-width: 500px; + padding: var(--spacing-lg); + background: linear-gradient(135deg, var(--color-primary), var(--color-accent)); + border-radius: 12px; + display: flex; + gap: var(--spacing-lg); + align-items: center; + animation: slideInUp 0.6s ease-out; + box-shadow: 0 10px 30px rgba(233, 69, 96, 0.4); +} + +.achievement-icon { + font-size: 3rem; +} + +.achievement-info { + flex: 1; +} + +.achievement-name { + font-size: 1.3rem; + font-weight: 700; + color: white; + margin-bottom: 0.25rem; +} + +.achievement-desc { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.9); +} + +/* Combo Notifications */ +.combo-notification { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0.5); + font-size: 2rem; + font-weight: 700; + color: #00ff88; + text-shadow: 0 0 20px #00ff88, 0 0 40px #00ff88; + animation: comboPopup 0.6s ease-out; + pointer-events: none; + z-index: 1000; + opacity: 0; + transition: opacity 0.3s ease; +} + +.combo-notification.show { + opacity: 1; +} + +@keyframes comboPopup { + 0% { + transform: translate(-50%, -50%) scale(0.5); + opacity: 1; + } + 50% { + transform: translate(-50%, -50%) scale(1.1); + } + 100% { + transform: translate(-50%, -80%) scale(1); + opacity: 0; + } +} + +/* Milestone Notifications */ +.milestone-notification { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0.3); + font-size: 2.5rem; + font-weight: 700; + background: linear-gradient(135deg, #FFD700, #FFA500); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.6)); + pointer-events: none; + z-index: 1001; + opacity: 0; + transition: opacity 0.3s ease; +} + +.milestone-notification.show { + opacity: 1; + animation: milestonePopup 0.6s ease-out; +} + +@keyframes milestonePopup { + 0% { + transform: translate(-50%, -50%) scale(0.3) rotateZ(-10deg); + opacity: 1; + } + 50% { + transform: translate(-50%, -50%) scale(1.15) rotateZ(0deg); + } + 100% { + transform: translate(-50%, -80%) scale(1) rotateZ(5deg); + opacity: 0; + } +} + +/* MODAL SYSTEM */ +.modal { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0.9); + background: var(--bg-primary); + border: 2px solid var(--color-primary); + border-radius: 12px; + max-width: 90vw; + max-height: 90vh; + z-index: 1000; + opacity: 0; + pointer-events: none; + transition: all var(--transition-normal); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.8); +} + +.modal.active { + opacity: 1; + pointer-events: all; + transform: translate(-50%, -50%) scale(1); +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 999; + opacity: 0; + pointer-events: none; + transition: opacity var(--transition-normal); +} + +.modal-overlay.active { + opacity: 1; + pointer-events: all; +} + +.modal-content { + padding: var(--spacing-lg); + overflow-y: auto; + max-height: 80vh; +} + +.modal-content-large { + width: 600px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-lg); + padding-bottom: var(--spacing-md); + border-bottom: 2px solid var(--color-primary); +} + +.modal-header h2 { + margin: 0; + font-size: 1.8rem; +} + +.modal-close { + background: transparent; + border: none; + color: var(--text-primary); + font-size: 2rem; + cursor: pointer; + line-height: 1; + transition: all var(--transition-fast); +} + +.modal-close:hover { + color: var(--color-accent); + transform: rotate(90deg); +} + +/* THEME SELECTOR */ +.theme-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--spacing-md); +} + +.theme-option { + padding: var(--spacing-md); + border: 2px solid var(--color-primary); + border-radius: 8px; + cursor: pointer; + transition: all var(--transition-normal); + text-align: center; + background: var(--bg-secondary); +} + +.theme-option:hover { + transform: translateY(-4px); + box-shadow: 0 10px 20px rgba(233, 69, 96, 0.3); + border-color: var(--color-accent); +} + +.theme-option.active { + background: var(--color-primary); + border-color: var(--color-accent); + box-shadow: 0 0 20px rgba(233, 69, 96, 0.5); +} + +.theme-preview { + width: 100%; + height: 60px; + border-radius: 6px; + margin-bottom: var(--spacing-sm); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.theme-name { + font-weight: 700; + margin-bottom: var(--spacing-xs); + font-size: 1rem; +} + +.theme-description { + font-size: 0.8rem; + color: var(--text-secondary); +} + +/* HISTORY */ +.history-controls { + display: flex; + gap: var(--spacing-md); + margin-bottom: var(--spacing-lg); + flex-wrap: wrap; +} + +.search-input { + flex: 1; + min-width: 200px; + padding: var(--spacing-sm); + border: 2px solid var(--color-primary); + border-radius: 6px; + background: var(--bg-secondary); + color: var(--text-primary); + font-family: inherit; +} + +.search-input:focus { + outline: none; + border-color: var(--color-accent); +} + +.history-list { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + max-height: 400px; + overflow-y: auto; +} + +.history-item { + padding: var(--spacing-md); + background: var(--bg-secondary); + border-left: 4px solid var(--color-primary); + border-radius: 4px; + transition: all var(--transition-normal); + animation: slideInUp 0.3s ease-out; +} + +.history-item:hover { + transform: translateX(4px); + border-left-color: var(--color-accent); + box-shadow: 0 4px 12px rgba(233, 69, 96, 0.2); +} + +.history-question { + font-weight: 600; + margin-bottom: var(--spacing-xs); + color: var(--text-primary); +} + +.history-answer { + font-size: 0.95rem; + color: var(--color-accent); + margin-bottom: var(--spacing-xs); + font-style: italic; +} + +.history-meta { + display: flex; + justify-content: space-between; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.history-btn { + display: flex; + gap: var(--spacing-xs); + align-items: center; +} + +.btn-icon-small { + cursor: pointer; + font-size: 1.1rem; + transition: all var(--transition-fast); + padding: 2px; +} + +.btn-icon-small:hover { + transform: scale(1.3); + color: var(--color-accent); +} + +.empty-message { + text-align: center; + color: var(--text-secondary); + padding: var(--spacing-xl) var(--spacing-md); + font-style: italic; +} + +/* ACHIEVEMENTS GRID */ +.achievements-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--spacing-md); +} + +.achievement-card { + padding: var(--spacing-md); + background: var(--bg-secondary); + border: 2px solid var(--color-primary); + border-radius: 8px; + text-align: center; + transition: all var(--transition-normal); + position: relative; +} + +.achievement-card.unlocked { + border-color: var(--color-accent); + background: linear-gradient(135deg, var(--bg-secondary), rgba(233, 69, 96, 0.1)); + box-shadow: 0 0 20px rgba(233, 69, 96, 0.3); +} + +.achievement-card.locked { + opacity: 0.6; +} + +.achievement-icon-large { + font-size: 3rem; + margin-bottom: var(--spacing-sm); +} + +.achievement-title { + font-weight: 700; + margin-bottom: var(--spacing-xs); + color: var(--text-primary); +} + +.achievement-text { + font-size: 0.8rem; + color: var(--text-secondary); + margin-bottom: var(--spacing-sm); +} + +.achievement-badge { + font-size: 0.75rem; + background: var(--color-accent); + color: white; + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: 12px; + font-weight: 700; +} + +/* FOOTER */ +.footer { + padding: var(--spacing-lg) var(--spacing-sm); + text-align: center; + color: var(--text-secondary); + font-size: 0.9rem; + border-top: 2px solid var(--color-primary); + animation: slideInUp 0.6s ease-out 0.5s both; +} + +/* SCROLLBAR */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--color-accent); + border-radius: 4px; +} + +/* MOBILE RESPONSIVENESS */ +@media (max-width: 768px) { + :root { + font-size: 14px; + } + + .header { + padding: var(--spacing-sm); + flex-direction: column; + gap: var(--spacing-sm); + } + + .title { + font-size: 1.8rem; + } + + .magic-ball { + width: 220px; + height: 220px; + } + + .ball-outer { + width: 220px; + height: 220px; + } + + .ball-inner { + width: 140px; + height: 140px; + } + + .ball-reflection { + width: 220px; + } + + .button-group { + flex-direction: column; + } + + .ask-btn, + .secondary-btn { + width: 100%; + } + + .modal-content-large { + width: 95vw; + } + + .theme-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + :root { + font-size: 13px; + } + + .title { + font-size: 1.5rem; + } + + .magic-ball { + width: 180px; + height: 180px; + } + + .ball-outer { + width: 180px; + height: 180px; + } + + .ball-inner { + width: 110px; + height: 110px; + } + + .ball-reflection { + width: 180px; + } + + .icon-btn { + width: 40px; + height: 40px; + font-size: 1.1rem; + } + + .theme-grid, + .achievements-grid { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/magic8ball/frontend/ui.js b/magic8ball/frontend/ui.js new file mode 100644 index 0000000..d1f890f --- /dev/null +++ b/magic8ball/frontend/ui.js @@ -0,0 +1,565 @@ +/** + * UI Module - Handles all UI interactions and DOM updates + */ + +class MagicBallUI { + constructor() { + this.domElements = {}; + this.init(); + } + + async init() { + this.cacheDOMElements(); + this.setupEventListeners(); + this.applyTheme(); + await this.loadThemes(); + await this.loadAchievements(); + this.updateStats(); + } + + cacheDOMElements() { + const ids = [ + 'magicBall', 'answerText', 'answerType', + 'questionInput', 'responsePack', 'askBtn', 'clearBtn', 'shareBtn', + 'themeToggleBtn', 'soundToggleBtn', 'historyBtn', 'achievementsBtn', + 'themeModal', 'historyModal', 'achievementsModal', 'modalOverlay', + 'closeThemeModal', 'closeHistoryModal', 'closeAchievementsModal', + 'themeGrid', 'historyList', 'clearHistoryBtn', 'historySearch', + 'charCount', 'totalQuestions', 'dailyCount', 'favoriteCount', + 'achievementToast', 'achievementIcon', 'achievementName', 'achievementDesc', + 'achievementsGrid' + ]; + + ids.forEach(id => { + const el = document.getElementById(id); + if (el) { + this.domElements[id] = el; + } + }); + } + + setupEventListeners() { + this.domElements.questionInput?.addEventListener('input', (e) => { + this.updateCharCount(); + if (e.key === 'Enter' && !game.isAsking) { + this.askQuestion(); + } + }); + + this.domElements.questionInput?.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && !game.isAsking) { + this.askQuestion(); + } + }); + + this.domElements.askBtn?.addEventListener('click', () => this.askQuestion()); + this.domElements.clearBtn?.addEventListener('click', () => this.clearQuestion()); + this.domElements.shareBtn?.addEventListener('click', () => this.shareResult()); + this.domElements.themeToggleBtn?.addEventListener('click', () => this.toggleThemeModal()); + this.domElements.soundToggleBtn?.addEventListener('click', () => this.toggleSound()); + this.domElements.historyBtn?.addEventListener('click', () => this.toggleHistoryModal()); + this.domElements.achievementsBtn?.addEventListener('click', () => this.toggleAchievementsModal()); + + this.domElements.closeThemeModal?.addEventListener('click', () => this.closeModal('themeModal')); + this.domElements.closeHistoryModal?.addEventListener('click', () => this.closeModal('historyModal')); + this.domElements.closeAchievementsModal?.addEventListener('click', () => this.closeModal('achievementsModal')); + this.domElements.modalOverlay?.addEventListener('click', () => this.closeAllModals()); + + this.domElements.historySearch?.addEventListener('input', (e) => { + this.filterHistory(e.target.value); + }); + + this.domElements.clearHistoryBtn?.addEventListener('click', () => { + if (confirm('Clear all history? This cannot be undone.')) { + game.clearHistory(); + this.updateStats(); + this.updateHistory(); + } + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.closeAllModals(); + } + }); + } + + updateCharCount() { + const count = this.domElements.questionInput?.value.length || 0; + if (this.domElements.charCount) { + this.domElements.charCount.textContent = count; + } + } + + async askQuestion() { + const question = this.domElements.questionInput?.value.trim(); + + if (!question) { + this.showError('Please ask a question first!'); + return; + } + + if (game.isAsking) { + return; + } + + game.isAsking = true; + this.domElements.askBtn.disabled = true; + this.domElements.askBtn.textContent = 'Thinking...'; + + try { + game.playSound('shake'); + + const ballOuter = this.domElements.magicBall?.querySelector('.ball-outer'); + if (ballOuter) { + ballOuter.classList.add('shaking'); + + await new Promise(resolve => + setTimeout(resolve, 600) + ); + + ballOuter.classList.remove('shaking'); + } + + await new Promise(resolve => + setTimeout(resolve, game.getAnimationDelay()) + ); + + const responsePack = this.domElements.responsePack?.value || 'default'; + const response = await api.ask(question, responsePack); + + game.playSound('reveal'); + + this.displayAnswer(response); + + game.addToHistory(question, response.answer, response.answer_type); + + this.updateStats(); + + const achievement = game.checkAchievements(); + if (achievement) { + this.showAchievement(achievement); + } + + this.clearQuestion(); + + } catch (error) { + console.error('Error:', error); + this.showError(error.message || 'Failed to get an answer. Please try again.'); + } finally { + game.isAsking = false; + this.domElements.askBtn.disabled = false; + this.updateAskButtonText(); + } + } + + displayAnswer(response) { + const answerText = this.domElements.answerText; + const answerType = this.domElements.answerType; + + if (!answerText || !answerType) return; + + answerText.textContent = 'Ask a question...'; + answerType.textContent = ''; + answerText.classList.remove('show'); + answerType.classList.remove('show'); + + setTimeout(() => { + answerText.textContent = response.answer; + answerType.textContent = response.answer_type.replace(/_/g, ' ').toUpperCase(); + answerText.classList.add('show'); + answerType.classList.add('show'); + + const ballOuter = this.domElements.magicBall?.querySelector('.ball-outer'); + if (ballOuter) { + ballOuter.classList.add('glowing'); + setTimeout(() => ballOuter.classList.remove('glowing'), 1000); + } + + // Show combo notification if combo > 1 + const combo = game.getCombo(); + if (combo > 2) { + this.showComboNotification(combo); + } + + // Show best combo milestone + if (game.getCombo() === game.getBestCombo() && game.getBestCombo() > 1) { + this.showMilestoneNotification(`New Personal Best! ${game.getBestCombo()} Combo! ๐Ÿ†`); + } + }, 100); + } + + showComboNotification(combo) { + const notification = document.createElement('div'); + notification.className = 'combo-notification'; + notification.textContent = `โœจ ${combo} Combo! โœจ`; + document.body.appendChild(notification); + + setTimeout(() => { + notification.classList.add('show'); + }, 10); + + setTimeout(() => { + notification.classList.remove('show'); + setTimeout(() => notification.remove(), 500); + }, 2000); + } + + showMilestoneNotification(message) { + const notification = document.createElement('div'); + notification.className = 'milestone-notification'; + notification.textContent = message; + document.body.appendChild(notification); + + setTimeout(() => { + notification.classList.add('show'); + }, 10); + + setTimeout(() => { + notification.classList.remove('show'); + setTimeout(() => notification.remove(), 500); + }, 3000); + } + + clearQuestion() { + if (this.domElements.questionInput) { + this.domElements.questionInput.value = ''; + this.updateCharCount(); + } + } + + updateAskButtonText() { + if (this.domElements.askBtn) { + this.domElements.askBtn.innerHTML = 'Ask the Magic 8 Ball๐ŸŽฑ'; + } + } + + shareResult() { + if (!game.currentAnswer) { + this.showError('Ask a question first!'); + return; + } + + const text = `I asked the Magic 8 Ball: "${this.domElements.questionInput?.value}"\n\nThe answer was: "${game.currentAnswer}"\n\nTry it yourself: Ask the Magic 8 Ball!`; + + if (navigator.share) { + navigator.share({ + title: 'Magic 8 Ball', + text: text + }).catch(err => console.log('Error sharing:', err)); + } else { + navigator.clipboard.writeText(text); + this.showNotification('Result copied to clipboard!'); + } + } + + toggleThemeModal() { + this.domElements.themeModal?.classList.toggle('active'); + this.domElements.modalOverlay?.classList.toggle('active'); + } + + toggleHistoryModal() { + if (this.domElements.historyModal?.classList.contains('active')) { + this.closeModal('historyModal'); + } else { + this.openModal('historyModal'); + this.updateHistory(); + } + } + + toggleAchievementsModal() { + if (this.domElements.achievementsModal?.classList.contains('active')) { + this.closeModal('achievementsModal'); + } else { + this.openModal('achievementsModal'); + this.renderAchievements(); + } + } + + openModal(modalId) { + const modal = this.domElements[modalId]; + if (modal) { + modal.classList.add('active'); + this.domElements.modalOverlay?.classList.add('active'); + } + } + + closeModal(modalId) { + const modal = this.domElements[modalId]; + if (modal) { + modal.classList.remove('active'); + } + if (!this.isAnyModalOpen()) { + this.domElements.modalOverlay?.classList.remove('active'); + } + } + + closeAllModals() { + Object.keys(this.domElements).forEach(key => { + if (key.includes('Modal')) { + this.domElements[key]?.classList.remove('active'); + } + }); + this.domElements.modalOverlay?.classList.remove('active'); + } + + isAnyModalOpen() { + return Array.from(document.querySelectorAll('.modal')).some(m => m.classList.contains('active')); + } + + async loadThemes() { + try { + const response = await api.getThemes(); + this.renderThemes(response.themes); + } catch (error) { + console.error('Failed to load themes:', error); + } + } + + renderThemes(themes) { + const grid = this.domElements.themeGrid; + if (!grid) return; + + grid.innerHTML = themes.map(theme => ` +
+
+
${theme.name}
+
${theme.description}
+
+ `).join(''); + + grid.querySelectorAll('.theme-option').forEach(option => { + option.addEventListener('click', () => { + const themeId = option.dataset.themeId; + this.selectTheme(themeId); + }); + }); + } + + selectTheme(themeId) { + game.setTheme(themeId); + this.applyTheme(); + + this.domElements.themeGrid?.querySelectorAll('.theme-option').forEach(option => { + option.classList.toggle('active', option.dataset.themeId === themeId); + }); + + game.playSound('click'); + } + + applyTheme() { + document.body.className = `theme-${game.currentTheme}`; + } + + toggleSound() { + const enabled = game.toggleSound(); + const icon = this.domElements.soundToggleBtn?.querySelector('.sound-icon'); + if (icon) { + icon.textContent = enabled ? '๐Ÿ”Š' : '๐Ÿ”‡'; + } + game.playSound('click'); + } + + updateStats() { + if (this.domElements.totalQuestions) { + this.domElements.totalQuestions.textContent = game.getTotalQuestions(); + } + if (document.getElementById('streakCount')) { + document.getElementById('streakCount').textContent = game.getStreak(); + } + if (document.getElementById('comboCount')) { + document.getElementById('comboCount').textContent = game.getCombo(); + } + if (document.getElementById('bestCombo')) { + document.getElementById('bestCombo').textContent = game.getBestCombo(); + } + } + + showAchievement(achievement) { + const section = this.domElements.achievementToast; + const icon = this.domElements.achievementIcon; + const name = this.domElements.achievementName; + const desc = this.domElements.achievementDesc; + + if (!section) return; + + if (icon) icon.textContent = achievement.icon; + if (name) name.textContent = achievement.name; + if (desc) desc.textContent = achievement.description; + + section.style.display = 'block'; + + setTimeout(() => { + section.style.display = 'none'; + }, 4000); + + game.playSound('reveal'); + } + + updateHistory() { + this.renderHistory(game.history); + } + + filterHistory(query) { + const results = game.searchHistory(query); + this.renderHistory(results); + } + + renderHistory(items) { + const list = this.domElements.historyList; + if (!list) return; + + if (items.length === 0) { + list.innerHTML = '

No history found.

'; + return; + } + + list.innerHTML = items.map(entry => ` +
+
Q: ${this.escapeHtml(entry.question)}
+
A: ${this.escapeHtml(entry.answer)}
+
+ ${this.formatDate(entry.timestamp)} +
+ + ${entry.is_favorite ? 'โค๏ธ' : '๐Ÿค'} + + ๐Ÿ“‹ + ๐Ÿ—‘๏ธ +
+
+
+ `).join(''); + + list.querySelectorAll('.favorite-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const id = parseInt(e.target.dataset.id); + game.toggleFavorite(id); + this.updateHistory(); + this.updateStats(); + game.playSound('click'); + }); + }); + + list.querySelectorAll('.copy-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const id = parseInt(e.target.dataset.id); + const entry = game.history.find(h => h.id === id); + if (entry) { + navigator.clipboard.writeText(entry.answer); + this.showNotification('Answer copied!'); + } + }); + }); + + list.querySelectorAll('.delete-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const id = parseInt(e.target.dataset.id); + game.history = game.history.filter(h => h.id !== id); + game.saveToLocalStorage(); + this.updateHistory(); + this.updateStats(); + }); + }); + } + + async loadAchievements() { + try { + const response = await api.getAchievements(); + game.allAchievements = response.achievements; + } catch (error) { + console.error('Failed to load achievements:', error); + } + } + + renderAchievements() { + const grid = this.domElements.achievementsGrid; + if (!grid || !game.allAchievements.length) return; + + grid.innerHTML = game.allAchievements.map(ach => { + const isUnlocked = game.achievements.includes(ach.id); + return ` +
+
${ach.icon}
+
${ach.name}
+
${ach.description}
+ ${isUnlocked ? '
โœ“ Unlocked
' : ''} +
+ `; + }).join(''); + } + + formatDate(timestamp) { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString(); + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + showNotification(message) { + const notification = document.createElement('div'); + notification.style.cssText = ` + position: fixed; + bottom: 20px; + right: 20px; + background: #e94560; + color: white; + padding: 15px 20px; + border-radius: 8px; + z-index: 2000; + animation: slideInUp 0.3s ease-out; + font-weight: 600; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3); + `; + notification.textContent = message; + document.body.appendChild(notification); + + setTimeout(() => { + notification.style.animation = 'slideInDown 0.3s ease-out'; + setTimeout(() => notification.remove(), 300); + }, 2000); + } + + showError(message) { + const notification = document.createElement('div'); + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: #d32f2f; + color: white; + padding: 15px 20px; + border-radius: 8px; + z-index: 2000; + animation: slideInDown 0.3s ease-out; + font-weight: 600; + max-width: 300px; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3); + `; + notification.textContent = message; + document.body.appendChild(notification); + + setTimeout(() => { + notification.style.animation = 'slideInUp 0.3s ease-out'; + setTimeout(() => notification.remove(), 300); + }, 4000); + } +} + +const ui = new MagicBallUI(); diff --git a/magic8ball/requirements.txt b/magic8ball/requirements.txt new file mode 100644 index 0000000..5fa4cf4 --- /dev/null +++ b/magic8ball/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-dotenv==1.0.0 +pytest==7.4.3 +pytest-asyncio==0.21.1 +httpx==0.25.2 +aiosqlite==0.19.0 +sqlalchemy==2.0.23 +pillow==10.1.0 diff --git a/magic8ball/start.bat b/magic8ball/start.bat new file mode 100644 index 0000000..752a78f --- /dev/null +++ b/magic8ball/start.bat @@ -0,0 +1,35 @@ +@echo off +REM Magic 8 Ball - Startup Script for Windows + +echo. +echo ๐ŸŽฑ Magic 8 Ball - Startup +echo ======================== +echo. + +REM Check if virtual environment exists +if not exist "venv" ( + echo ๐Ÿ“ฆ Creating virtual environment... + python -m venv venv +) + +REM Activate virtual environment +echo โœจ Activating virtual environment... +call venv\Scripts\activate.bat + +REM Install dependencies +echo ๐Ÿ“ฅ Installing dependencies... +pip install -r requirements.txt -q + +REM Run tests +echo ๐Ÿงช Running tests... +pytest tests/ -v --tb=short + +REM Start the server +echo. +echo ๐Ÿš€ Starting Magic 8 Ball server... +echo ๐Ÿ“ URL: http://localhost:8000 +echo ๐Ÿ›‘ Press Ctrl+C to stop +echo. + +python -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000 +pause diff --git a/magic8ball/start.sh b/magic8ball/start.sh new file mode 100644 index 0000000..2992270 --- /dev/null +++ b/magic8ball/start.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Magic 8 Ball - Startup Script + +echo "๐ŸŽฑ Magic 8 Ball - Startup" +echo "========================" +echo "" + +# Check if virtual environment exists +if [ ! -d "venv" ]; then + echo "๐Ÿ“ฆ Creating virtual environment..." + python -m venv venv +fi + +# Activate virtual environment +echo "โœจ Activating virtual environment..." +source venv/bin/activate + +# Install dependencies +echo "๐Ÿ“ฅ Installing dependencies..." +pip install -r requirements.txt --quiet + +# Run tests +echo "๐Ÿงช Running tests..." +pytest tests/ -v --tb=short + +# Start the server +echo "" +echo "๐Ÿš€ Starting Magic 8 Ball server..." +echo " ๐Ÿ“ URL: http://localhost:8000" +echo " ๐Ÿ›‘ Press Ctrl+C to stop" +echo "" + +python -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000 diff --git a/magic8ball/tests/__init__.py b/magic8ball/tests/__init__.py new file mode 100644 index 0000000..7909f2d --- /dev/null +++ b/magic8ball/tests/__init__.py @@ -0,0 +1,15 @@ +""" +Magic 8 Ball - Tests Configuration + +Run all tests: + pytest tests/ -v + +Run specific test class: + pytest tests/test_backend.py::TestMagic8BallCore -v + +Run with coverage: + pytest tests/ --cov=backend --cov-report=html + +Run specific test: + pytest tests/test_backend.py::TestMagic8BallCore::test_ask_valid_question -v +""" diff --git a/magic8ball/tests/test_backend.py b/magic8ball/tests/test_backend.py new file mode 100644 index 0000000..bae3ee1 --- /dev/null +++ b/magic8ball/tests/test_backend.py @@ -0,0 +1,264 @@ +"""Comprehensive tests for Magic 8 Ball backend""" +import pytest +from pathlib import Path +import json +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) + +from magic8ball import Magic8Ball, AnswerType +from models import QuestionRequest, AnswerResponse + + +@pytest.fixture +def magic_ball(): + """Create Magic 8 Ball instance for testing""" + return Magic8Ball() + + +@pytest.fixture +def responses_file(tmp_path): + """Create temporary responses file""" + responses = { + "positive": ["Yes", "Definitely"], + "negative": ["No", "Never"], + "neutral": ["Maybe", "Ask again"], + "easter_eggs": ["๐ŸŽฑ"] + } + file_path = tmp_path / "test_responses.json" + with open(file_path, 'w') as f: + json.dump(responses, f) + return file_path + + +class TestMagic8BallCore: + """Test core Magic 8 Ball functionality""" + + def test_initialization(self, magic_ball): + """Test Magic 8 Ball initializes correctly""" + assert magic_ball is not None + assert magic_ball.easter_egg_chance == 0.05 + assert len(magic_ball.responses) == 4 + assert all( + answer_type in magic_ball.responses + for answer_type in [ + AnswerType.POSITIVE, + AnswerType.NEGATIVE, + AnswerType.NEUTRAL, + AnswerType.EASTER_EGG + ] + ) + + def test_load_responses(self, responses_file): + """Test loading custom responses""" + ball = Magic8Ball(str(responses_file)) + assert len(ball.responses[AnswerType.POSITIVE]) == 2 + assert "Yes" in ball.responses[AnswerType.POSITIVE] + + def test_responses_not_empty(self, magic_ball): + """Test all response types have answers""" + for answer_type in [ + AnswerType.POSITIVE, + AnswerType.NEGATIVE, + AnswerType.NEUTRAL, + AnswerType.EASTER_EGG + ]: + assert len(magic_ball.responses[answer_type]) > 0 + + def test_ask_valid_question(self, magic_ball): + """Test asking a valid question""" + answer, answer_type = magic_ball.ask("Will I succeed?") + + assert isinstance(answer, str) + assert len(answer) > 0 + assert isinstance(answer_type, AnswerType) + assert answer_type in [ + AnswerType.POSITIVE, + AnswerType.NEGATIVE, + AnswerType.NEUTRAL, + AnswerType.EASTER_EGG + ] + + def test_ask_empty_question(self, magic_ball): + """Test empty question raises error""" + with pytest.raises(ValueError): + magic_ball.ask("") + + with pytest.raises(ValueError): + magic_ball.ask(" ") + + def test_ask_invalid_response_pack(self, magic_ball): + """Test invalid response pack raises error""" + with pytest.raises(ValueError): + magic_ball.ask("Will I win?", "invalid_pack") + + def test_all_response_packs(self, magic_ball): + """Test all response packs work""" + question = "Am I awesome?" + packs = magic_ball.get_available_packs() + + for pack in packs: + answer, answer_type = magic_ball.ask(question, pack) + assert isinstance(answer, str) + assert len(answer) > 0 + + def test_randomness(self, magic_ball): + """Test answer variation""" + question = "Is this random?" + answers = set() + + for _ in range(20): + answer, _ = magic_ball.ask(question) + answers.add(answer) + + assert len(answers) > 1 + + def test_get_available_packs(self, magic_ball): + """Test getting available packs""" + packs = magic_ball.get_available_packs() + assert isinstance(packs, list) + assert len(packs) >= 4 + assert "default" in packs + assert "funny" in packs + assert "serious" in packs + assert "motivational" in packs + + def test_get_response_count(self, magic_ball): + """Test response counts""" + counts = magic_ball.get_response_count() + + assert isinstance(counts, dict) + for key in ["positive", "negative", "neutral", "easter_eggs"]: + assert key in counts + assert counts[key] > 0 + + +class TestPydanticModels: + """Test Pydantic validation models""" + + def test_question_request_valid(self): + """Test valid question request""" + req = QuestionRequest(question="Will I be happy?") + assert req.question == "Will I be happy?" + assert req.response_pack == "default" + + def test_question_request_with_pack(self): + """Test question with response pack""" + req = QuestionRequest( + question="Am I cool?", + response_pack="funny" + ) + assert req.response_pack == "funny" + + def test_question_whitespace_stripped(self): + """Test whitespace stripping""" + req = QuestionRequest(question=" Will I win? ") + assert req.question == "Will I win?" + + def test_question_empty_fails(self): + """Test empty question fails""" + with pytest.raises(ValueError): + QuestionRequest(question="") + + def test_question_too_long_fails(self): + """Test max length validation""" + long_question = "a" * 501 + with pytest.raises(ValueError): + QuestionRequest(question=long_question) + + def test_answer_response(self): + """Test answer response model""" + resp = AnswerResponse( + answer="Yes", + answer_type="positive", + response_pack="default", + question="Will I succeed?" + ) + + assert resp.answer == "Yes" + assert resp.answer_type == "positive" + + +class TestBalancedDistribution: + """Test answer distribution balance""" + + def test_distribution(self): + """Test balanced distribution""" + ball = Magic8Ball() + distribution = { + AnswerType.POSITIVE: 0, + AnswerType.NEGATIVE: 0, + AnswerType.NEUTRAL: 0, + AnswerType.EASTER_EGG: 0 + } + + trials = 1000 + for _ in range(trials): + _, answer_type = ball.ask("Test?") + distribution[answer_type] += 1 + + positive_ratio = distribution[AnswerType.POSITIVE] / trials + negative_ratio = distribution[AnswerType.NEGATIVE] / trials + + assert 0.3 < positive_ratio < 0.5 + assert 0.3 < negative_ratio < 0.5 + + +class TestResponsePacks: + """Test response packs""" + + def test_all_packs_have_all_types(self): + """Test pack completeness""" + ball = Magic8Ball() + + for pack_name in ball.get_available_packs(): + pack = ball.response_packs[pack_name] + assert AnswerType.POSITIVE in pack + assert AnswerType.NEGATIVE in pack + assert AnswerType.NEUTRAL in pack + assert AnswerType.EASTER_EGG in pack + + def test_funny_pack(self): + """Test funny response pack""" + ball = Magic8Ball() + answer, _ = ball.ask("Is this funny?", "funny") + assert isinstance(answer, str) + + def test_serious_pack(self): + """Test serious response pack""" + ball = Magic8Ball() + answer, _ = ball.ask("Is this serious?", "serious") + assert isinstance(answer, str) + + def test_motivational_pack(self): + """Test motivational response pack""" + ball = Magic8Ball() + answer, _ = ball.ask("Am I motivated?", "motivational") + assert isinstance(answer, str) + + +class TestEdgeCases: + """Test edge cases""" + + def test_unicode_questions(self): + """Test unicode support""" + ball = Magic8Ball() + answer, _ = ball.ask("Will I find ๐Ÿ’Ž? ๐ŸŽฑ") + assert isinstance(answer, str) + + def test_long_question(self): + """Test long questions""" + ball = Magic8Ball() + long_question = "Is this a very long question? " * 10 + answer, _ = ball.ask(long_question[:500]) + assert isinstance(answer, str) + + def test_special_characters(self): + """Test special characters""" + ball = Magic8Ball() + answer, _ = ball.ask("Will I win @#$%? (!@#$)") + assert isinstance(answer, str) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])