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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Today's Streak ๐ฅ
+
0
+
+
+
Current Combo โจ
+
0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No history yet. Ask a question to get started!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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"])