diff --git a/main.py b/main.py index a0940816..173c83ac 100644 --- a/main.py +++ b/main.py @@ -56,7 +56,9 @@ INVENTORY_SERVICE_URL = os.getenv('INVENTORY_SERVICE_URL', 'http://inventory-ser # In-memory caches for REST endpoints _cached_live: dict = {"players": []} _cached_trails: dict = {"trails": []} +_cached_total_rares: dict = {"all_time": 0, "today": 0, "last_updated": None} _cache_task: asyncio.Task | None = None +_rares_cache_task: asyncio.Task | None = None async def _refresh_cache_loop() -> None: """Background task: refresh `/live` and `/trails` caches every 5 seconds.""" @@ -131,10 +133,61 @@ async def _refresh_cache_loop() -> None: await asyncio.sleep(5) +async def _refresh_total_rares_cache() -> None: + """Background task: refresh total rares cache every 5 minutes.""" + consecutive_failures = 0 + max_consecutive_failures = 3 + + while True: + try: + async with database.connection() as conn: + # Get all-time total rares (sum of all characters) - gracefully handle missing table + try: + all_time_query = "SELECT COALESCE(SUM(total_rares), 0) as total FROM rare_stats" + all_time_result = await conn.fetch_one(all_time_query) + all_time_total = all_time_result["total"] if all_time_result else 0 + except Exception as e: + logger.debug(f"rare_stats table not available: {e}") + all_time_total = 0 + + # Get today's rares from rare_events table - gracefully handle missing table + try: + today_query = """ + SELECT COUNT(*) as today_count + FROM rare_events + WHERE timestamp >= CURRENT_DATE + """ + today_result = await conn.fetch_one(today_query) + today_total = today_result["today_count"] if today_result else 0 + except Exception as e: + logger.debug(f"rare_events table not available or empty: {e}") + today_total = 0 + + # Update cache + _cached_total_rares["all_time"] = all_time_total + _cached_total_rares["today"] = today_total + _cached_total_rares["last_updated"] = datetime.now(timezone.utc) + + consecutive_failures = 0 + logger.debug(f"Total rares cache updated: All-time: {all_time_total}, Today: {today_total}") + + except Exception as e: + consecutive_failures += 1 + logger.error(f"Total rares cache refresh failed ({consecutive_failures}/{max_consecutive_failures}): {e}", exc_info=True) + + if consecutive_failures >= max_consecutive_failures: + logger.warning("Too many consecutive total rares cache failures, waiting longer...") + await asyncio.sleep(60) # Wait longer on repeated failures + continue + + # Sleep for 5 minutes (300 seconds) + await asyncio.sleep(300) + # ------------------------------------------------------------------ app = FastAPI() # In-memory store mapping character_name to the most recent telemetry snapshot live_snapshots: Dict[str, dict] = {} +live_vitals: Dict[str, dict] = {} # Shared secret used to authenticate plugin WebSocket connections (override for production) SHARED_SECRET = "your_shared_secret" @@ -212,6 +265,25 @@ class FullInventoryMessage(BaseModel): items: List[Dict[str, Any]] +class VitalsMessage(BaseModel): + """ + Model for the vitals WebSocket message type. + Contains character health, stamina, mana, and vitae information. + """ + character_name: str + timestamp: datetime + health_current: int + health_max: int + health_percentage: float + stamina_current: int + stamina_max: int + stamina_percentage: float + mana_current: int + mana_max: int + mana_percentage: float + vitae: int + + @app.on_event("startup") async def on_startup(): """Event handler triggered when application starts up. @@ -238,17 +310,18 @@ async def on_startup(): else: raise RuntimeError(f"Could not connect to database after {max_attempts} attempts") # Start background cache refresh (live & trails) - global _cache_task + global _cache_task, _rares_cache_task _cache_task = asyncio.create_task(_refresh_cache_loop()) - logger.info("Background cache refresh task started") + _rares_cache_task = asyncio.create_task(_refresh_total_rares_cache()) + logger.info("Background cache refresh tasks started") @app.on_event("shutdown") async def on_shutdown(): """Event handler triggered when application is shutting down. Ensures the database connection is closed cleanly. """ - # Stop cache refresh task - global _cache_task + # Stop cache refresh tasks + global _cache_task, _rares_cache_task if _cache_task: logger.info("Stopping background cache refresh task") _cache_task.cancel() @@ -256,6 +329,14 @@ async def on_shutdown(): await _cache_task except asyncio.CancelledError: pass + + if _rares_cache_task: + logger.info("Stopping total rares cache refresh task") + _rares_cache_task.cancel() + try: + await _rares_cache_task + except asyncio.CancelledError: + pass logger.info("Disconnecting from database") await database.disconnect() @@ -294,6 +375,17 @@ async def get_trails( raise HTTPException(status_code=500, detail="Internal server error") +@app.get("/total-rares") +@app.get("/total-rares/") +async def get_total_rares(): + """Return cached total rares statistics (updated every 5 minutes).""" + try: + return JSONResponse(content=jsonable_encoder(_cached_total_rares)) + except Exception as e: + logger.error(f"Failed to get total rares: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + # --- GET Inventory Endpoints --------------------------------- @app.get("/inventory/{character_name}") async def get_character_inventory(character_name: str): @@ -669,6 +761,8 @@ async def ws_receive_snapshots( rare_events.insert().values(**rare_ev.dict()) ) logger.info(f"Recorded rare event: {rare_ev.name} by {name}") + # Broadcast rare event to browser clients for epic notifications + await _broadcast_to_browser_clients(data) except Exception as e: logger.error(f"Failed to persist rare event: {e}") except Exception as e: @@ -690,6 +784,18 @@ async def ws_receive_snapshots( except Exception as e: logger.error(f"Failed to process inventory for {data.get('character_name', 'unknown')}: {e}", exc_info=True) continue + # --- Vitals message: store character health/stamina/mana and broadcast --- + if msg_type == "vitals": + payload = data.copy() + payload.pop("type", None) + try: + vitals_msg = VitalsMessage.parse_obj(payload) + live_vitals[vitals_msg.character_name] = vitals_msg.dict() + await _broadcast_to_browser_clients(data) + logger.debug(f"Updated vitals for {vitals_msg.character_name}: {vitals_msg.health_percentage}% HP, {vitals_msg.stamina_percentage}% Stam, {vitals_msg.mana_percentage}% Mana") + except Exception as e: + logger.error(f"Failed to process vitals for {data.get('character_name', 'unknown')}: {e}", exc_info=True) + continue # Unknown message types are ignored if msg_type: logger.warning(f"Unknown message type '{msg_type}' from {websocket.client}") diff --git a/static/index.html b/static/index.html index 12e10e8b..25d00ada 100644 --- a/static/index.html +++ b/static/index.html @@ -17,12 +17,33 @@
-

Active Players

+

Active Mosswart Enjoyers

+ +
+ 🔥 Total Rares: Loading... +
+ +
+ ⚡ Server KPH: Loading... +
+ +
+ ⚔️ Total Kills: Loading... +
+ + + + +
+ + +
+
diff --git a/static/script.js b/static/script.js index 5a95bab0..8eb14fba 100644 --- a/static/script.js +++ b/static/script.js @@ -930,10 +930,32 @@ async function pollLive() { } } +async function pollTotalRares() { + try { + const response = await fetch(`${API_BASE}/total-rares/`); + const data = await response.json(); + updateTotalRaresDisplay(data); + } catch (e) { + console.error('Total rares fetch failed:', e); + } +} + +function updateTotalRaresDisplay(data) { + const countElement = document.getElementById('totalRaresCount'); + if (countElement && data.all_time !== undefined && data.today !== undefined) { + const allTimeFormatted = data.all_time.toLocaleString(); + const todayFormatted = data.today.toLocaleString(); + countElement.textContent = `${allTimeFormatted} (Today: ${todayFormatted})`; + } +} + function startPolling() { if (pollID !== null) return; pollLive(); + pollTotalRares(); // Initial fetch pollID = setInterval(pollLive, POLL_MS); + // Poll total rares every 5 minutes (300,000 ms) + setInterval(pollTotalRares, 300000); } img.onload = () => { @@ -968,6 +990,43 @@ function render(players) { dots.innerHTML = ''; list.innerHTML = ''; + // Update header with active player count + const header = document.getElementById('activePlayersHeader'); + if (header) { + header.textContent = `Active Mosswart Enjoyers (${players.length})`; + } + + // Calculate and update server KPH + const totalKPH = players.reduce((sum, p) => sum + (p.kills_per_hour || 0), 0); + const kphElement = document.getElementById('serverKphCount'); + if (kphElement) { + // Format with commas and one decimal place for EPIC display + const formattedKPH = totalKPH.toLocaleString('en-US', { + minimumFractionDigits: 1, + maximumFractionDigits: 1 + }); + kphElement.textContent = formattedKPH; + + // Add extra epic effect for high KPH + const container = document.getElementById('serverKphCounter'); + if (container) { + if (totalKPH > 5000) { + container.classList.add('ultra-epic'); + } else { + container.classList.remove('ultra-epic'); + } + } + } + + // Calculate and update total kills + const totalKills = players.reduce((sum, p) => sum + (p.total_kills || 0), 0); + const killsElement = document.getElementById('totalKillsCount'); + if (killsElement) { + // Format with commas for readability + const formattedKills = totalKills.toLocaleString(); + killsElement.textContent = formattedKills; + } + players.forEach(p => { const { x, y } = worldToPx(p.ew, p.ns); @@ -1002,7 +1061,8 @@ function render(players) { const kpr = totalRares > 0 ? Math.round(totalKills / totalRares) : '∞'; li.innerHTML = ` - ${p.character_name} ${loc(p.ns, p.ew)} + ${p.character_name}${createVitaeIndicator(p.character_name)} ${loc(p.ns, p.ew)} + ${createVitalsHTML(p.character_name)} ${p.kills} ${p.total_kills || 0} ${p.kills_per_hour} @@ -1110,6 +1170,10 @@ function initWebSocket() { try { msg = JSON.parse(evt.data); } catch { return; } if (msg.type === 'chat') { appendChatMessage(msg); + } else if (msg.type === 'vitals') { + updateVitalsDisplay(msg); + } else if (msg.type === 'rare') { + triggerEpicRareNotification(msg.character_name, msg.name); } }); socket.addEventListener('close', () => setTimeout(initWebSocket, 2000)); @@ -1276,3 +1340,310 @@ wrap.addEventListener('mousemove', e => { wrap.addEventListener('mouseleave', () => { coordinates.style.display = 'none'; }); + +/* ---------- vitals display functions ----------------------------- */ +// Store vitals data per character +const characterVitals = {}; + +function updateVitalsDisplay(vitalsMsg) { + // Store the vitals data for this character + characterVitals[vitalsMsg.character_name] = { + health_percentage: vitalsMsg.health_percentage, + stamina_percentage: vitalsMsg.stamina_percentage, + mana_percentage: vitalsMsg.mana_percentage, + vitae: vitalsMsg.vitae + }; + + // Re-render the player list to update vitals in the UI + renderList(); +} + +function createVitalsHTML(characterName) { + const vitals = characterVitals[characterName]; + if (!vitals) { + return ''; // No vitals data available + } + + return ` +
+
+
+
+
+
+
+
+
+
+
+ `; +} + +function createVitaeIndicator(characterName) { + const vitals = characterVitals[characterName]; + if (!vitals || !vitals.vitae || vitals.vitae >= 100) { + return ''; // No vitae penalty + } + + return `⚰️ ${vitals.vitae}%`; +} + +function getVitalClass(percentage) { + if (percentage <= 25) { + return 'critical-vital'; + } else if (percentage <= 50) { + return 'low-vital'; + } + return ''; +} + +/* ---------- epic rare notification system ------------------------ */ +// Track previous rare count to detect increases +let lastRareCount = 0; +let notificationQueue = []; +let isShowingNotification = false; + +function triggerEpicRareNotification(characterName, rareName) { + // Add to queue + notificationQueue.push({ characterName, rareName }); + + // Process queue if not already showing a notification + if (!isShowingNotification) { + processNotificationQueue(); + } + + // Trigger fireworks immediately + createFireworks(); + + // Highlight the player in the list + highlightRareFinder(characterName); +} + +function processNotificationQueue() { + if (notificationQueue.length === 0) { + isShowingNotification = false; + return; + } + + isShowingNotification = true; + const notification = notificationQueue.shift(); + + // Create notification element + const container = document.getElementById('rareNotifications'); + const notifEl = document.createElement('div'); + notifEl.className = 'rare-notification'; + notifEl.innerHTML = ` +
🎆 LEGENDARY RARE! 🎆
+
${notification.rareName}
+
found by
+
⚔️ ${notification.characterName} ⚔️
+ `; + + container.appendChild(notifEl); + + // Remove notification after 6 seconds and process next + setTimeout(() => { + notifEl.style.animation = 'notification-slide-out 0.5s ease-in forwards'; + setTimeout(() => { + notifEl.remove(); + processNotificationQueue(); + }, 500); + }, 6000); +} + +// Add slide out animation +const style = document.createElement('style'); +style.textContent = ` + @keyframes notification-slide-out { + to { + transform: translateY(-100px); + opacity: 0; + } + } +`; +document.head.appendChild(style); + +function createFireworks() { + const container = document.getElementById('fireworksContainer'); + const rareCounter = document.getElementById('totalRaresCounter'); + const rect = rareCounter.getBoundingClientRect(); + + // Create 30 particles + const particleCount = 30; + const colors = ['particle-gold', 'particle-red', 'particle-orange', 'particle-purple', 'particle-blue']; + + for (let i = 0; i < particleCount; i++) { + const particle = document.createElement('div'); + particle.className = `firework-particle ${colors[Math.floor(Math.random() * colors.length)]}`; + + // Start position at rare counter + particle.style.left = `${rect.left + rect.width / 2}px`; + particle.style.top = `${rect.top + rect.height / 2}px`; + + // Random explosion direction + const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5; + const velocity = 100 + Math.random() * 200; + const dx = Math.cos(angle) * velocity; + const dy = Math.sin(angle) * velocity - 50; // Slight upward bias + + // Create custom animation for this particle + const animName = `particle-${Date.now()}-${i}`; + const keyframes = ` + @keyframes ${animName} { + 0% { + transform: translate(0, 0) scale(1); + opacity: 1; + } + 100% { + transform: translate(${dx}px, ${dy + 200}px) scale(0); + opacity: 0; + } + } + `; + + const styleEl = document.createElement('style'); + styleEl.textContent = keyframes; + document.head.appendChild(styleEl); + + particle.style.animation = `${animName} 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`; + + container.appendChild(particle); + + // Clean up particle and animation after completion + setTimeout(() => { + particle.remove(); + styleEl.remove(); + }, 2000); + } +} + +function highlightRareFinder(characterName) { + // Find the player in the list + const playerItems = document.querySelectorAll('#playerList li'); + playerItems.forEach(item => { + const nameSpan = item.querySelector('.player-name'); + if (nameSpan && nameSpan.textContent.includes(characterName)) { + item.classList.add('rare-finder-glow'); + // Remove glow after 5 seconds + setTimeout(() => { + item.classList.remove('rare-finder-glow'); + }, 5000); + } + }); +} + +// Update total rares display to trigger fireworks on increase +const originalUpdateTotalRaresDisplay = updateTotalRaresDisplay; +updateTotalRaresDisplay = function(data) { + originalUpdateTotalRaresDisplay(data); + + // Check if total increased + const newTotal = data.all_time || 0; + if (newTotal > lastRareCount && lastRareCount > 0) { + // Don't trigger on initial load + createFireworks(); + + // Check for milestones when count increases + if (newTotal > 0 && newTotal % 100 === 0) { + triggerMilestoneCelebration(newTotal); + } + } + lastRareCount = newTotal; +} + +function triggerMilestoneCelebration(rareNumber) { + console.log(`🏆 MILESTONE: Rare #${rareNumber}! 🏆`); + + // Create full-screen milestone overlay + const overlay = document.createElement('div'); + overlay.className = 'milestone-overlay'; + + overlay.innerHTML = ` +
+
#${rareNumber}
+
🏆 EPIC MILESTONE! 🏆
+
Server Achievement Unlocked
+
+ `; + + document.body.appendChild(overlay); + + // Add screen shake effect + document.body.classList.add('screen-shake'); + + // Create massive firework explosion + createMilestoneFireworks(); + + // Remove milestone overlay after 5 seconds + setTimeout(() => { + overlay.style.animation = 'milestone-fade-in 0.5s ease-out reverse'; + document.body.classList.remove('screen-shake'); + setTimeout(() => { + overlay.remove(); + }, 500); + }, 5000); +} + +function createMilestoneFireworks() { + const container = document.getElementById('fireworksContainer'); + + // Create multiple bursts across the screen + const burstCount = 5; + const particlesPerBurst = 50; + const colors = ['#ffd700', '#ff6600', '#ff0044', '#cc00ff', '#00ccff', '#00ff00']; + + for (let burst = 0; burst < burstCount; burst++) { + setTimeout(() => { + // Random position for each burst + const x = Math.random() * window.innerWidth; + const y = Math.random() * (window.innerHeight * 0.7) + (window.innerHeight * 0.15); + + for (let i = 0; i < particlesPerBurst; i++) { + const particle = document.createElement('div'); + particle.className = 'milestone-particle'; + particle.style.background = colors[Math.floor(Math.random() * colors.length)]; + particle.style.boxShadow = `0 0 12px ${particle.style.background}`; + + // Start position + particle.style.left = `${x}px`; + particle.style.top = `${y}px`; + + // Random explosion direction + const angle = (Math.PI * 2 * i) / particlesPerBurst + (Math.random() - 0.5) * 0.8; + const velocity = 200 + Math.random() * 300; + const dx = Math.cos(angle) * velocity; + const dy = Math.sin(angle) * velocity - 100; // Upward bias + + // Create custom animation + const animName = `milestone-particle-${Date.now()}-${burst}-${i}`; + const keyframes = ` + @keyframes ${animName} { + 0% { + transform: translate(0, 0) scale(1); + opacity: 1; + } + 100% { + transform: translate(${dx}px, ${dy + 400}px) scale(0); + opacity: 0; + } + } + `; + + const styleEl = document.createElement('style'); + styleEl.textContent = keyframes; + document.head.appendChild(styleEl); + + particle.style.animation = `${animName} 3s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`; + + container.appendChild(particle); + + // Clean up + setTimeout(() => { + particle.remove(); + styleEl.remove(); + }, 3000); + } + }, burst * 200); // Stagger bursts + } +} + diff --git a/static/style.css b/static/style.css index 1f3fc519..5de2c5c4 100644 --- a/static/style.css +++ b/static/style.css @@ -122,6 +122,104 @@ body { font-size: 1.25rem; color: var(--accent); } + +.total-rares-counter { + margin: 0 0 12px 0; + padding: 8px 12px; + background: linear-gradient(135deg, #2a2a2a, #1a1a1a); + border: 1px solid #444; + border-radius: 6px; + font-size: 0.95rem; + font-weight: 600; + color: #ffd700; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.total-rares-counter #totalRaresCount { + color: #fff; + margin-left: 4px; +} + +.server-kph-counter { + margin: 0 0 12px 0; + padding: 9px 12px; + background: linear-gradient(135deg, #2a2a44, #1a1a33); + border: 2px solid #4466aa; + border-radius: 6px; + font-size: 1rem; + font-weight: 600; + color: #aaccff; + text-align: center; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4); + position: relative; + animation: kph-border-glow 4s ease-in-out infinite; +} + +@keyframes kph-border-glow { + 0%, 100% { border-color: #4466aa; box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4); } + 50% { border-color: #6688cc; box-shadow: 0 3px 12px rgba(102, 136, 204, 0.3); } +} + +.server-kph-counter #serverKphCount { + color: #fff; + margin-left: 4px; + font-size: 1.1rem; + font-weight: 700; + text-shadow: 0 0 8px rgba(255, 255, 255, 0.3); + animation: kph-pulse 3s ease-in-out infinite; +} + +@keyframes kph-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.02); } +} + +/* ULTRA MODE for KPH > 5000 */ +.server-kph-counter.ultra-epic { + background: linear-gradient(135deg, #6644ff, #4422cc, #6644ff); + background-size: 200% 200%; + animation: kph-border-glow 4s ease-in-out infinite, ultra-background 3s ease-in-out infinite; + border-color: #8866ff; + color: #eeeeff; + box-shadow: 0 4px 12px rgba(102, 68, 255, 0.5); +} + +@keyframes ultra-background { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +.server-kph-counter.ultra-epic #serverKphCount { + font-size: 1.3rem; + color: #ffffff; + text-shadow: 0 0 12px rgba(255, 255, 255, 0.7); + animation: kph-pulse 3s ease-in-out infinite, ultra-glow 2s ease-in-out infinite alternate; +} + +@keyframes ultra-glow { + from { text-shadow: 0 0 12px rgba(255, 255, 255, 0.7); } + to { text-shadow: 0 0 18px rgba(255, 255, 255, 0.9), 0 0 25px rgba(136, 102, 255, 0.5); } +} + +.total-kills-counter { + margin: 0 0 12px 0; + padding: 8px 12px; + background: linear-gradient(135deg, #2a2a2a, #1a1a1a); + border: 1px solid #555; + border-radius: 6px; + font-size: 0.95rem; + font-weight: 600; + color: #ff6666; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.total-kills-counter #totalKillsCount { + color: #fff; + margin-left: 4px; +} #playerList { list-style: none; margin: 0; @@ -243,9 +341,10 @@ body { #playerList li { display: grid; grid-template-columns: 1fr auto auto auto auto auto; - grid-template-rows: auto auto auto auto; + grid-template-rows: auto auto auto auto auto; grid-template-areas: "name name name name name name" + "vitals vitals vitals vitals vitals vitals" "kills totalkills kph kph kph kph" "rares kpr meta meta meta meta" "onlinetime deaths tapers tapers tapers tapers"; @@ -254,7 +353,7 @@ body { padding: 8px 10px; background: var(--card); border-left: 4px solid transparent; - transition: background 0.15s; + transition: none; font-size: 0.85rem; } @@ -272,6 +371,8 @@ body { .stat.deaths { grid-area: deaths; } .stat.tapers { grid-area: tapers; } +.player-vitals { grid-area: vitals; } + /* pill styling */ #playerList li .stat { background: rgba(255,255,255,0.1); @@ -719,3 +820,359 @@ body.noselect, body.noselect * { margin-top: 4px; text-align: center; } + +/* ---------- inline vitals bars ---------------------------------- */ +.player-vitals { + grid-column: 1 / -1; + margin: 2px 0 4px 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.vital-bar-inline { + height: 5px; + background: #222; + border-radius: 3px; + overflow: hidden; + position: relative; +} + +.vitae-indicator { + font-size: 0.75rem; + color: #ff6666; + margin-left: 8px; + font-weight: 500; +} + +.vital-fill { + height: 100%; + transition: width 0.3s ease-out; + border-radius: 2px; +} + +.vital-fill.health { + background: linear-gradient(90deg, #ff4444, #ff6666); +} + +.vital-fill.stamina { + background: linear-gradient(90deg, #ffaa00, #ffcc44); +} + +.vital-fill.mana { + background: linear-gradient(90deg, #4488ff, #66aaff); +} + +/* Pulsing effects for low vitals */ +.vital-bar-inline.low-vital { + animation: pulse-bar-low 2s ease-in-out infinite; +} + +.vital-bar-inline.critical-vital { + animation: pulse-bar-critical 1s ease-in-out infinite; +} + +@keyframes pulse-bar-low { + 0%, 100% { background: #222; } + 50% { background: #332200; } +} + +@keyframes pulse-bar-critical { + 0%, 100% { background: #222; } + 50% { background: #440000; } +} + +/* ---------- epic rare notifications ------------------------------ */ +.rare-notifications { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 10001; + pointer-events: none; +} + +.rare-notification { + background: linear-gradient(135deg, #ffd700, #ffed4e, #ffd700); + border: 3px solid #ff6600; + border-radius: 12px; + padding: 20px 30px; + margin-bottom: 10px; + text-align: center; + box-shadow: 0 8px 32px rgba(255, 215, 0, 0.5); + animation: notification-slide-in 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55), + epic-glow 2s ease-in-out infinite; + position: relative; + overflow: hidden; +} + +@keyframes notification-slide-in { + from { + transform: translateY(-100px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes epic-glow { + 0%, 100% { + box-shadow: 0 8px 32px rgba(255, 215, 0, 0.5); + } + 50% { + box-shadow: 0 8px 48px rgba(255, 215, 0, 0.8); + } +} + +.rare-notification-title { + font-size: 1.2rem; + font-weight: 800; + color: #ff0044; + text-transform: uppercase; + margin-bottom: 8px; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); + animation: epic-text-pulse 1s ease-in-out infinite; +} + +@keyframes epic-text-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +.rare-notification-mob { + font-size: 1.5rem; + font-weight: 700; + color: #1a0033; + margin-bottom: 4px; + text-shadow: 2px 2px 4px rgba(255, 255, 255, 0.5); +} + +.rare-notification-finder { + font-size: 1rem; + color: #333; + font-style: italic; + margin-bottom: 4px; +} + +.rare-notification-character { + font-size: 1.3rem; + font-weight: 700; + color: #ff0044; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); +} + +/* Shine effect overlay */ +.rare-notification::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: linear-gradient(45deg, + transparent 30%, + rgba(255, 255, 255, 0.5) 50%, + transparent 70% + ); + transform: rotate(45deg); + animation: notification-shine 3s infinite; +} + +@keyframes notification-shine { + 0% { transform: translateX(-100%) translateY(-100%) rotate(45deg); } + 100% { transform: translateX(100%) translateY(100%) rotate(45deg); } +} + +/* ---------- fireworks particles ---------------------------------- */ +.fireworks-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 9999; +} + +.firework-particle { + position: absolute; + width: 6px; + height: 6px; + border-radius: 50%; + pointer-events: none; + animation: firework-fly 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; +} + +@keyframes firework-fly { + 0% { + transform: translate(0, 0) scale(1); + opacity: 1; + } + 100% { + opacity: 0; + } +} + +/* Different particle colors */ +.particle-gold { background: #ffd700; box-shadow: 0 0 6px #ffd700; } +.particle-red { background: #ff4444; box-shadow: 0 0 6px #ff4444; } +.particle-orange { background: #ff8800; box-shadow: 0 0 6px #ff8800; } +.particle-purple { background: #cc00ff; box-shadow: 0 0 6px #cc00ff; } +.particle-blue { background: #00ccff; box-shadow: 0 0 6px #00ccff; } + +/* Character glow effect in player list */ +.player-item.rare-finder-glow { + animation: rare-finder-highlight 5s ease-in-out; + border-left-color: #ffd700 !important; + border-left-width: 6px !important; +} + +@keyframes rare-finder-highlight { + 0%, 100% { + background: var(--card); + box-shadow: none; + } + 50% { + background: rgba(255, 215, 0, 0.2); + box-shadow: 0 0 20px rgba(255, 215, 0, 0.5); + } +} + +/* ---------- milestone celebration overlay ------------------------ */ +.milestone-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: radial-gradient(ellipse at center, rgba(255, 215, 0, 0.3), rgba(0, 0, 0, 0.8)); + z-index: 20000; + display: flex; + align-items: center; + justify-content: center; + animation: milestone-fade-in 0.5s ease-out; +} + +@keyframes milestone-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.milestone-content { + text-align: center; + animation: milestone-zoom 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55); +} + +@keyframes milestone-zoom { + from { + transform: scale(0); + } + to { + transform: scale(1); + } +} + +.milestone-number { + font-size: 8rem; + font-weight: 900; + color: #ffd700; + text-shadow: + 0 0 30px #ffd700, + 0 0 60px #ff6600, + 0 0 90px #ff0044, + 0 0 120px #ff0044; + margin-bottom: 20px; + animation: milestone-pulse 1s ease-in-out infinite alternate; +} + +@keyframes milestone-pulse { + from { + transform: scale(1); + text-shadow: + 0 0 30px #ffd700, + 0 0 60px #ff6600, + 0 0 90px #ff0044, + 0 0 120px #ff0044; + } + to { + transform: scale(1.1); + text-shadow: + 0 0 40px #ffd700, + 0 0 80px #ff6600, + 0 0 120px #ff0044, + 0 0 160px #ff0044; + } +} + +.milestone-text { + font-size: 3rem; + font-weight: 700; + color: #fff; + text-transform: uppercase; + letter-spacing: 0.2em; + text-shadow: 0 0 20px rgba(255, 255, 255, 0.8); + animation: milestone-text-glow 2s ease-in-out infinite; +} + +@keyframes milestone-text-glow { + 0%, 100% { + opacity: 0.8; + } + 50% { + opacity: 1; + } +} + +.milestone-subtitle { + font-size: 1.5rem; + color: #ffcc00; + margin-top: 20px; + font-style: italic; + animation: milestone-subtitle-slide 1s ease-out; +} + +@keyframes milestone-subtitle-slide { + from { + transform: translateY(50px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Milestone firework burst - larger particles */ +.milestone-particle { + position: absolute; + width: 12px; + height: 12px; + border-radius: 50%; + pointer-events: none; + background: #ffd700; + box-shadow: 0 0 12px #ffd700; +} + +/* Screen shake effect */ +@keyframes screen-shake { + 0%, 100% { transform: translate(0, 0); } + 10% { transform: translate(-5px, -5px); } + 20% { transform: translate(5px, -5px); } + 30% { transform: translate(-5px, 5px); } + 40% { transform: translate(5px, 5px); } + 50% { transform: translate(-3px, -3px); } + 60% { transform: translate(3px, -3px); } + 70% { transform: translate(-3px, 3px); } + 80% { transform: translate(3px, 3px); } + 90% { transform: translate(-1px, -1px); } +} + +.screen-shake { + animation: screen-shake 0.5s ease-in-out; +}