// --- Embeddings API Configuration --- // Si estamos en localhost para dev, apuntamos al puerto 8000. Si estamos en producción o en un Space de HF, usamos el origin actual. const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; const EMBEDDINGS_API_URL = isLocalhost ? "http://localhost:8000" : window.location.origin; // Game State let CORRECT_WORD = null; let gameNumber = 0; let attempts = 0; let guesses = []; let gameWon = false; let startTime = Date.now(); let isLoading = false; let serverOnline = false; let isDailyMode = true; // Default to daily challenge let currentDailyIdentifier = null; // Tracks current day based on server time async function fetchRandomWord(exclude = '') { const url = `${EMBEDDINGS_API_URL}/random-word${exclude ? `?exclude=${encodeURIComponent(exclude)}` : ''}`; const response = await fetch(url); if (!response.ok) throw new Error('Could not fetch word'); const data = await response.json(); return data.word; } async function fetchDailyWord() { const url = `${EMBEDDINGS_API_URL}/daily-word`; const response = await fetch(url); if (!response.ok) throw new Error('Could not fetch daily word'); const data = await response.json(); return data; } // DOM Elements const input = document.getElementById('word-input'); const submitBtn = document.getElementById('btn-submit'); const guessesList = document.getElementById('guesses-list'); const victoryModal = document.getElementById('victory-modal'); const helpModal = document.getElementById('help-modal'); const statsModal = document.getElementById('stats-modal'); const btnHelp = document.getElementById('btn-help'); const btnStats = document.getElementById('btn-stats'); const btnCloseHelp = document.getElementById('btn-close-help'); const btnCloseStats = document.getElementById('btn-close-stats'); const btnCloseStatsAction = document.getElementById('btn-close-stats-action'); const btnUnderstood = document.getElementById('btn-understood'); const btnShare = document.getElementById('btn-share'); const btnNewGame = document.getElementById('btn-new-game'); const btnNewWord = document.getElementById('btn-new-word'); // Share Modal Elements const shareModal = document.getElementById('share-modal'); const btnCloseShare = document.getElementById('btn-close-share'); const btnShareWa = document.getElementById('btn-share-wa'); const btnShareTw = document.getElementById('btn-share-tw'); const btnShareFb = document.getElementById('btn-share-fb'); const btnShareCopy = document.getElementById('btn-share-copy'); const btnHint = document.getElementById('btn-hint'); const btnGiveUp = document.getElementById('btn-give-up'); // New Modal Elements const messageModal = document.getElementById('message-modal'); const confirmModal = document.getElementById('confirm-modal'); const btnCloseMessage = document.getElementById('btn-close-message'); const btnMessageOk = document.getElementById('btn-message-ok'); const btnConfirmCancel = document.getElementById('btn-confirm-cancel'); const btnConfirmOk = document.getElementById('btn-confirm-ok'); // Server status element const serverStatusEl = document.getElementById('server-status'); // Initialize document.addEventListener('DOMContentLoaded', async () => { input.disabled = true; submitBtn.disabled = true; input.focus(); const online = await checkServerHealth(); if (online) { try { const dailyData = await fetchDailyWord(); const dailyWord = dailyData.word; currentDailyIdentifier = dailyData.date; // Check if user has won today's daily word to enforce it const dailyStateJSON = localStorage.getItem(`MF_STATE_DAILY_${currentDailyIdentifier}`); let hasWonDaily = false; if (dailyStateJSON) { const ds = JSON.parse(dailyStateJSON); hasWonDaily = ds.gameWon; } // Determine which mode to load const savedMode = localStorage.getItem('MF_LAST_MODE'); // Forced to daily if it's a new day and hasn't been won if (!hasWonDaily) { isDailyMode = true; } else { isDailyMode = savedMode === null ? true : (savedMode === 'daily'); } if (isDailyMode) { CORRECT_WORD = dailyWord; gameNumber = dailyData.game_number; loadGameState(currentDailyIdentifier); } else { // If last mode was practice, try to load it, otherwise fallback to daily if (!loadGameState('practice')) { isDailyMode = true; CORRECT_WORD = dailyWord; gameNumber = dailyData.game_number; loadGameState(currentDailyIdentifier); } } updateGameModeUI(); console.log(`🎮 Game loaded. Mode: ${isDailyMode ? 'Daily' : 'Practice'}`); } catch (e) { console.error('Initial load failed:', e); startNewGame(); } } else { // Offline fallback CORRECT_WORD = 'TIEMPO'; currentDailyIdentifier = 'offline'; loadGameState('offline'); updateGameModeUI(); } input.disabled = false; submitBtn.disabled = false; input.focus(); setInterval(checkServerHealth, 30000); }); async function checkServerHealth() { try { const response = await fetch(`${EMBEDDINGS_API_URL}/health`, { signal: AbortSignal.timeout(3000) }); if (response.ok) { serverOnline = true; updateServerStatus(true); return true; } } catch { // Server not reachable } serverOnline = false; updateServerStatus(false); return false; } function updateServerStatus(online) { if (!serverStatusEl) return; serverStatusEl.classList.toggle('offline', !online); serverStatusEl.title = online ? 'Servidor online' : 'Servidor offline'; } // Help button const btnHelpFloat = document.getElementById('btn-help-float'); const openHelp = () => { console.log("Help opened"); helpModal.classList.remove('hidden'); // Focus "¡Entendido!" button setTimeout(() => document.getElementById('btn-understood').focus(), 50); }; btnHelp.addEventListener('click', openHelp); if (btnHelpFloat) btnHelpFloat.addEventListener('click', openHelp); btnCloseHelp.addEventListener('click', () => helpModal.classList.add('hidden')); btnUnderstood.addEventListener('click', () => helpModal.classList.add('hidden')); // Stats button const btnStatsFloat = document.getElementById('btn-stats-float'); const openStats = () => { if (gameWon) { victoryModal.classList.remove('hidden'); // Focus first action button in victory modal setTimeout(() => victoryModal.querySelector('button')?.focus(), 50); } else { updateStatsModal(); statsModal.classList.remove('hidden'); setTimeout(() => statsModal.querySelector('button.btn-primary')?.focus(), 50); } }; btnStats.addEventListener('click', openStats); if (btnStatsFloat) btnStatsFloat.addEventListener('click', openStats); btnCloseStats.addEventListener('click', () => statsModal.classList.add('hidden')); btnCloseStatsAction.addEventListener('click', () => statsModal.classList.add('hidden')); // Close modals on backdrop click const allModals = [victoryModal, helpModal, statsModal, messageModal, confirmModal, shareModal]; allModals.forEach(modal => { modal.addEventListener('click', (e) => { if (e.target === modal) { closeActiveModal(); } }); }); // Keyboard Navigation for Modals document.addEventListener('keydown', (e) => { const activeModal = allModals.find(m => !m.classList.contains('hidden')); if (!activeModal) return; // 1. ESC to close/cancel if (e.key === 'Escape') { e.preventDefault(); closeActiveModal(); return; } // Identify focusable buttons in the modal const buttons = Array.from(activeModal.querySelectorAll('button:not([disabled]):not(.modal-close)')); if (buttons.length === 0) return; const focusedIdx = buttons.indexOf(document.activeElement); // 2. ENTER to accept if (e.key === 'Enter') { // If nothing is focused but there's only one button or a primary one, click it if (focusedIdx === -1) { const primary = activeModal.querySelector('.btn-primary') || buttons[0]; if (primary) primary.click(); } else { // If something is focused, Enter naturally clicks it (browser default), // but we can ensure it here if needed. } return; } // 3. Arrow Keys to move focus if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); const nextIdx = (focusedIdx + 1) % buttons.length; buttons[nextIdx].focus(); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); const prevIdx = (focusedIdx - 1 + buttons.length) % buttons.length; buttons[prevIdx].focus(); } }); function closeActiveModal() { const activeModal = allModals.find(m => !m.classList.contains('hidden')); if (!activeModal) return; if (activeModal === confirmModal) { if (document.getElementById('btn-confirm-cancel')) document.getElementById('btn-confirm-cancel').click(); } else if (activeModal === messageModal) { if (document.getElementById('btn-close-message')) document.getElementById('btn-close-message').click(); } else if (activeModal === helpModal) { document.getElementById('btn-close-help').click(); } else if (activeModal === statsModal) { document.getElementById('btn-close-stats').click(); } else if (activeModal === victoryModal) { // Just hide it activeModal.classList.add('hidden'); } else { activeModal.classList.add('hidden'); } } // --- Mobile Toolbar: wire mob-btns to the same handlers as desktop buttons --- const mobBtns = [ ['btn-new-game-mob', 'btn-new-game'], ['btn-hint-mob', 'btn-hint'], ['btn-give-up-mob', 'btn-give-up'], ['btn-stats-mob', 'btn-stats'], ['btn-help-mob', 'btn-help'], ]; mobBtns.forEach(([mobId, desktopId]) => { const mobEl = document.getElementById(mobId); const deskEl = document.getElementById(desktopId); if (mobEl && deskEl) mobEl.addEventListener('click', () => deskEl.click()); }); // Back to Top Button Logic const btnBackToTop = document.getElementById('btn-back-to-top'); const gameBoard = document.querySelector('.game-board'); if (gameBoard && btnBackToTop) { gameBoard.addEventListener('scroll', () => { // Show button if scrolled down more than 300px if (gameBoard.scrollTop > 300) { btnBackToTop.classList.remove('hidden'); } else { btnBackToTop.classList.add('hidden'); } }); btnBackToTop.addEventListener('click', () => { gameBoard.scrollTo({ top: 0, behavior: 'smooth' }); }); } function updateStatsModal() { document.getElementById('stats-attempts').textContent = attempts; document.getElementById('stats-words').textContent = guesses.length; const bestRank = guesses.length > 0 ? Math.min(...guesses.map(g => g.rank)) : null; document.getElementById('stats-best-rank').textContent = bestRank ? `#${bestRank}` : '—'; // Status emoji let status = '🔍'; if (bestRank && bestRank <= 10) status = '🔥'; else if (bestRank && bestRank <= 100) status = '🎯'; else if (bestRank && bestRank <= 500) status = '👀'; else if (bestRank && bestRank <= 1500) status = '🌤️'; else if (bestRank) status = '❄️'; document.getElementById('stats-status').textContent = status; // Progress bar (logarithmic scale: rank 50000 = 0%, rank 1 = 100%) let progressPercent = 0; let progressText = 'Sin intentos'; if (bestRank) { progressPercent = Math.max(2, Math.min(100, 100 - (Math.log(bestRank) / Math.log(50000)) * 100)); if (bestRank <= 10) progressText = '¡Casi la tienes!'; else if (bestRank <= 100) progressText = 'Muy cerca'; else if (bestRank <= 500) progressText = 'Calentando'; else if (bestRank <= 1500) progressText = 'Tibio'; else progressText = 'Buscando...'; } document.getElementById('stats-progress-fill').style.width = `${progressPercent}%`; document.getElementById('stats-progress-text').textContent = progressText; // Hint const hintEl = document.getElementById('stats-hint'); if (bestRank && bestRank <= 100) { hintEl.querySelector('span').textContent = '¡Vas muy bien! Prueba sinónimos de tus mejores palabras.'; } else if (bestRank && bestRank <= 1000) { hintEl.querySelector('span').textContent = 'Estás en la dirección correcta. Piensa en palabras del mismo campo semántico.'; } else { hintEl.querySelector('span').textContent = 'Escribe palabras para descubrir la palabra secreta.'; } } submitBtn.addEventListener('click', handleGuess); input.addEventListener('keypress', (e) => { if (e.key === 'Enter') handleGuess(); }); btnShare.addEventListener('click', shareResult); btnNewGame.addEventListener('click', async () => { // 1. Check if midnight passed on the server if (serverOnline) { try { const dailyData = await fetchDailyWord(); if (currentDailyIdentifier && dailyData.date !== currentDailyIdentifier) { // Midnight passed, new day! currentDailyIdentifier = dailyData.date; CORRECT_WORD = dailyData.word; gameNumber = dailyData.game_number; isDailyMode = true; // Force daily mode await showMessage("¡Nuevo Día!", "Ya es medianoche. El juego se reinició a la nueva palabra diaria obligatoria."); loadGameState(currentDailyIdentifier); updateGameModeUI(); input.value = ''; input.disabled = false; submitBtn.disabled = false; input.focus(); return; // Stop practice game start } } catch (e) { console.error("Failed to check midnight status", e); } } // Self-healing: Ensure gameWon is true if the word has been found if (guesses.some(g => g.rank === 1)) { gameWon = true; } if (isDailyMode && !gameWon) { await showMessage("Palabra Diaria", "Para jugar al Modo Infinito primero tenés que adivinar la palabra del día de hoy o rendirte."); return; } if (!isDailyMode && attempts > 0 && !gameWon) { const confirmed = await showConfirm("Nueva Palabra", "¿Seguro que querés abandonar esta partida y empezar otra?"); if (!confirmed) return; } startNewGame(); }); btnNewWord.addEventListener('click', () => { victoryModal.classList.add('hidden'); btnNewGame.click(); // Share logic to check midnight and modes }); btnHint.addEventListener('click', () => { console.log("Hint button clicked"); handleHint(); }); btnGiveUp.addEventListener('click', async () => { if (!gameWon && attempts > 0) { const confirmed = await showConfirm("Rendirse", "¿Seguro que te rendís? Se revelará la palabra secreta."); if (!confirmed) return; } handleGiveUp(); }); // Share Modal Buttons btnCloseShare.addEventListener('click', () => shareModal.classList.add('hidden')); btnShareWa.addEventListener('click', () => { const text = getShareText(); const url = `https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`; window.open(url, '_blank'); }); btnShareTw.addEventListener('click', () => { const text = getShareText(); const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}`; window.open(url, '_blank'); }); btnShareFb.addEventListener('click', () => { // Facebook only allows sharing a URL, not custom text directly in the sharer // We'll share the current page URL const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(window.location.href)}`; window.open(url, '_blank'); }); btnShareCopy.addEventListener('click', async () => { const text = getShareText(); try { await navigator.clipboard.writeText(text); const icon = btnShareCopy.querySelector('i'); const span = btnShareCopy.querySelector('span'); const originalIcon = icon.className; const originalText = span.innerText; // Should be 'Copiar' icon.className = 'fa-solid fa-check'; span.innerText = '¡Copiado!'; setTimeout(() => { icon.className = originalIcon; span.innerText = originalText; }, 2000); } catch (err) { console.error('Failed to copy: ', err); } }); async function handleGuess() { if (gameWon || isLoading) return; // Check server status if (!serverOnline) { const isBack = await checkServerHealth(); if (!isBack) { await showMessage("Servidor Desconectado", "⚠️ El servidor de embeddings no está activo.\n\nEjecuta el servidor con:\n cd server\n python app.py"); return; } } const word = input.value.trim().toUpperCase(); // 1. Basic validation if (!word) return; if (word.length < 2) { await showMessage("Palabra muy corta", "La palabra debe tener al menos 2 letras."); return; } // 2. Validate only letters (including Spanish characters) if (!/^[A-ZÁÉÍÓÚÑÜ]+$/.test(word)) { await showMessage("Caracteres inválidos", "Solo se permiten letras."); return; } // 3. Check if already guessed (before calling API) if (guesses.some(g => g.word === word)) { shakeInput(); input.value = ''; return; } // UI Loading State setLoading(true); try { const result = await getRankFromEmbeddings(word); if (!result.found) { await showMessage("Palabra no encontrada", `La palabra "${word}" no se encuentra en el diccionario.\nPrueba con otra palabra.`); return; } attempts++; const guessResult = { word: word, rank: result.rank, similarity: result.similarity, id: Date.now() }; guesses.push(guessResult); guesses.sort((a, b) => a.rank - b.rank); // Sort by rank (1 is best) renderGuesses(guessResult.id); saveGameState(); input.value = ''; input.focus(); if (result.is_correct) { triggerWin(); } } catch (error) { console.error("Error fetching rank:", error); if (error.message.includes("RATE_LIMIT")) { await showMessage("Pausa", "Te quedaste sin energía. Estás enviando muchas palabras muy rápido.\nPor favor, esperá un minuto para seguir jugando y no agotar los recursos de HuggingFace."); } else { await showMessage("Error", "Ocurrió un error al consultar el servidor de embeddings.\nVerifica que esté corriendo."); } } finally { setLoading(false); } } async function getRankFromEmbeddings(guessWord) { // 1. Direct check for correct word (Zero cost) if (guessWord === CORRECT_WORD) { return { rank: 1, similarity: 1.0, found: true, normalized_word: guessWord.toLowerCase(), is_correct: true }; } // 2. Check Local Cache (Zero cost) const cacheKey = `MF_EMB_${CORRECT_WORD}_${guessWord}`; const cachedResult = localStorage.getItem(cacheKey); if (cachedResult) { const parsed = JSON.parse(cachedResult); console.log(`%c Cache Hit: ${guessWord} -> #${parsed.rank}`, 'color: cyan'); return parsed; } // 3. Call local embeddings server const response = await fetch(`${EMBEDDINGS_API_URL}/rank`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ guess: guessWord, secret: CORRECT_WORD }) }); if (!response.ok) { const errText = await response.text(); console.error("Embeddings API Error:", response.status, errText); if (response.status === 429) { throw new Error("RATE_LIMIT"); } throw new Error(`Embeddings API Error: ${response.status}`); } const data = await response.json(); // 4. Save to Cache if (data.found) { localStorage.setItem(cacheKey, JSON.stringify(data)); } return data; } async function handleHint() { if (gameWon || isLoading || !serverOnline) return; const confirmed = await showConfirm("¿Pedir Pista?", "¿Quieres una pista? El juego continuará pero contará como un intento."); if (!confirmed) { return; } setLoading(true); try { const response = await fetch(`${EMBEDDINGS_API_URL}/hint`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ secret: CORRECT_WORD, excluded_words: guesses.map(g => g.word) }) }); if (!response.ok) { if (response.status === 429) { throw new Error("RATE_LIMIT"); } throw new Error("Hint failed"); } const result = await response.json(); attempts++; const hintResult = { word: result.normalized_word.toUpperCase(), rank: result.rank, similarity: result.similarity, id: Date.now(), isHint: true }; guesses.push(hintResult); guesses.sort((a, b) => a.rank - b.rank); renderGuesses(hintResult.id); saveGameState(); } catch (error) { console.error("Hint error:", error); if (error.message.includes("RATE_LIMIT")) { await showMessage("Pausa", "Te quedaste sin energía. Estás enviando peticiones muy rápido.\nPor favor, esperá un minuto para pedir más pistas."); } else { await showMessage("Error", "No se pudo obtener una pista."); } } finally { setLoading(false); } } async function handleGiveUp() { if (gameWon) return; const confirmed = await showConfirm("¿Rendirse?", "¿Estás seguro de que quieres rendirte? Se revelará la palabra secreta."); if (confirmed) { triggerWin(true); // true means gave up } } function setLoading(loading) { isLoading = loading; if (loading) { submitBtn.innerHTML = ''; submitBtn.disabled = true; input.disabled = true; } else { submitBtn.innerHTML = ''; submitBtn.disabled = false; input.disabled = false; input.focus(); } } function renderGuesses(newGuessId = null) { guessesList.innerHTML = ''; let displayGuesses = [...guesses]; // Mover el nuevo intento al principio de la lista visualmente if (newGuessId) { const newGuessIndex = displayGuesses.findIndex(g => g.id === newGuessId); if (newGuessIndex !== -1) { const newGuess = displayGuesses.splice(newGuessIndex, 1)[0]; displayGuesses.unshift(newGuess); } } displayGuesses.forEach(guess => { const item = document.createElement('div'); // Determine class & status based on rank let rankClass = 'rank-low'; let statusText = 'LEJOS'; if (guess.rank === 1) { rankClass = 'rank-1'; statusText = 'ENCONTRADA'; } else if (guess.rank <= 300) { rankClass = 'rank-high'; statusText = 'MUY CERCA'; } else if (guess.rank <= 1500) { rankClass = 'rank-med'; statusText = 'TIBIO'; } item.className = `guess-item ${rankClass}`; // Highlight new card if (guess.id === newGuessId) { item.classList.add('new-guess'); // Scroll al top para asegurar que se ve la caja de input y la nueva palabra setTimeout(() => { const gameBoard = document.querySelector('.game-board'); if (gameBoard) { gameBoard.scrollTo({ top: 0, behavior: 'smooth' }); } else { window.scrollTo({ top: 0, behavior: 'smooth' }); } }, 100); } // Card Layout: Info (Left) + Rank (Right) item.innerHTML = `