// Kairós — raíz de la app: salas (estado compartido en vivo), navegación, // cronómetro y tweaks. const { useState, useEffect, useRef } = React; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "timerMinutes": 15, "showLinks": true } /*EDITMODE-END*/; const STORE_KEY = 'kairos-proto-v1'; const MAX_SPRINT = 5; // los mazos cubren los sprints 1..5 function loadStore() { try {return JSON.parse(localStorage.getItem(STORE_KEY)) || {};} catch (e) {return {};} } // --- mazos servidos por la BBDD (api/cards.php) --- function deckForNode(nodeId) { if (nodeId === 'DEC') return 'decide'; if (nodeId === 'VOR') return 'vortice'; if (nodeId === 'EXP') return 'explora'; return null; // RESPIRO y NÚCLEO aún no tienen mazo } function deckKey(deck, monumentId, sprint) { return deck === 'decide' ? 'decide:' + monumentId + ':' + sprint : deck + ':' + sprint; } function deckUrl(deck, monumentId, sprint) { return deck === 'decide' ? 'api/cards.php?deck=decide&monument=' + encodeURIComponent(monumentId) + '&sprint=' + sprint : 'api/cards.php?deck=' + deck + '&sprint=' + sprint; } // carta al azar del mazo EXCLUYENDO las ya usadas por este equipo en este sprint. // Una vez usada, una carta no vuelve a salir; si se agotan todas, devuelve null // (mazo exhausto) — el que actúa NO repite ninguna. function pickCard(cards, usedIds) { if (!cards || !cards.length) return null; const used = Array.isArray(usedIds) ? usedIds : []; const avail = cards.filter((c) => used.indexOf(c.id) === -1); if (!avail.length) return null; // todas usadas en este sprint return avail[Math.floor(Math.random() * avail.length)]; } // Interpreta el efecto mecánico de una carta VÓRTICE (texto libre como // "+3 Materia", "-2 Sistema", "+2 Energía · +1 Sistema", "-dado TC") y lo // convierte en deltas {MAT,ENE,SIS,TC}. Solo la 1ª línea (antes de la acción 🚶). // "dado" lanza un dado 1-6. Si no hay número, devuelve deltas vacíos. function parseEffectDeltas(text) { const out = { deltas: {}, note: '' }; if (!text) return out; const firstLine = String(text).split('\n')[0]; const map = { materia: 'MAT', energia: 'ENE', 'energía': 'ENE', sistema: 'SIS', tc: 'TC' }; const re = /([+\-−])\s*(\d+|dado)\s*(Materia|Energía|Energia|Sistema|TC)/gi; let m, found = false; while ((m = re.exec(firstLine)) !== null) { found = true; const sign = (m[1] === '-' || m[1] === '−') ? -1 : 1; let val; if (/dado/i.test(m[2])) { val = 1 + Math.floor(Math.random() * 6); out.note = 'dado = ' + val; } else val = parseInt(m[2], 10); const key = map[m[3].toLowerCase()]; if (key) out.deltas[key] = (out.deltas[key] || 0) + sign * val; } if (!found) out.note = 'sin efecto numérico directo'; return out; } // ===================== SALAS (estado compartido en vivo) ===================== // Modelo «espejo total»: todas las sesiones de una sala comparten exactamente el // mismo estado del juego. El cronómetro NO se sincroniza (es local por pantalla). // Transporte por GET a propósito: el WAF del servidor bloquea los POST a /api. const ROOM_API = 'api/room.php'; const POLL_MS = 1200; // sondeo de cambios remotos (ajustable) // Trackers POR MONUMENTO: cada monumento es su propia partida y NO comparte // puntuación con los demás (siguen siendo compartidos en vivo dentro de la sala). // Valores de inicio comunes. const STATS_DEFAULT = { MAT: 6, ENE: 7, SIS: 5, TC: 12 }; function defaultStatsByMonument() { const out = {}; MONUMENTS.forEach((m) => { out[m.id] = Object.assign({}, STATS_DEFAULT); }); return out; } // canonicaliza los trackers de UN monumento (claves/orden fijos, valores numéricos; // los que falten toman el valor de inicio). Para que firma == estado. function buildStats(s) { const x = s || {}; return { MAT: typeof x.MAT === 'number' ? x.MAT : STATS_DEFAULT.MAT, ENE: typeof x.ENE === 'number' ? x.ENE : STATS_DEFAULT.ENE, SIS: typeof x.SIS === 'number' ? x.SIS : STATS_DEFAULT.SIS, TC: typeof x.TC === 'number' ? x.TC : STATS_DEFAULT.TC, }; } // canonicaliza el mapa monumento -> trackers (orden de monumentos fijo) function buildStatsByMonument(sbm) { const src = sbm || {}; const out = {}; MONUMENTS.forEach((m) => { out[m.id] = buildStats(src[m.id]); }); return out; } // canonicaliza `resolved` a una forma FIJA (claves y orden estables). Imprescindible // para el anti-eco: la firma siempre pasa por aquí, así da igual el orden de claves // con el que el servidor (que sanea el estado) devuelva resolved — la firma coincide. function buildResolved(r) { if (!r || typeof r !== 'object') return null; const a = (r.applied && typeof r.applied === 'object') ? r.applied : {}; const num = (v) => (typeof v === 'number' ? v : 0); return { choice: (typeof r.choice === 'string') ? r.choice : null, applied: { MAT: num(a.MAT), ENE: num(a.ENE), SIS: num(a.SIS), TC: num(a.TC) }, note: (typeof r.note === 'string') ? r.note : null, }; } // canonicaliza el mapa de cartas usadas: { 'decide:coliseo:1':['id',...], 'vortice:1':[...] } // con ORDEN ESTABLE (claves y valores ordenados, sin duplicados) para que la firma // anti-eco sea determinista pase lo que pase con el orden que devuelva el servidor. // Acepta [] (PHP puede serializar el mapa vacío como array) y lo normaliza a {}. function buildUsedCards(u) { const src = (u && typeof u === 'object') ? u : {}; const out = {}; Object.keys(src).sort().forEach((k) => { const arr = Array.isArray(src[k]) ? src[k] : []; const seen = {}; const clean = []; arr.forEach((x) => { if (typeof x === 'string' && !seen[x]) { seen[x] = 1; clean.push(x); } }); if (clean.length) out[k] = clean.sort(); }); return out; } // estado de sala normalizado, con ORDEN DE CLAVES ESTABLE (para comparar firmas) function buildRoomState(view, monumentId, nodeId, sprint, statsByMonument, timer, cardId, resolved, usedCards) { return { view: view || 'monumentos', monumentId: monumentId || null, nodeId: nodeId || null, sprint: Math.min(MAX_SPRINT, Math.max(1, Number(sprint) || 1)), // acotado como en hydrate (firma == estado) statsByMonument: buildStatsByMonument(statsByMonument), timer: buildTimer(timer), cardId: cardId || null, resolved: buildResolved(resolved), usedCards: buildUsedCards(usedCards), }; } function roomStateSig(stateObj) { return JSON.stringify(stateObj); } // base64 seguro con UTF-8 (acentos en resolved.note); el WAF no filtra base64 por GET function roomEncodeState(stateObj) { const json = JSON.stringify(stateObj); return btoa(encodeURIComponent(json).replace(/%([0-9A-F]{2})/g, function (_, h) { return String.fromCharCode(parseInt(h, 16)); })); } function roomCreate() { return fetch(ROOM_API + '?action=create').then((r) => r.json()); } function roomGet(code, since) { return fetch(ROOM_API + '?action=get&code=' + encodeURIComponent(code) + '&since=' + (since | 0)).then((r) => r.json()); } function roomSave(code, stateObj) { const s = roomEncodeState(stateObj); return fetch(ROOM_API + '?action=save&code=' + encodeURIComponent(code) + '&s=' + encodeURIComponent(s)).then((r) => r.json()); } // --- CRONÓMETRO COMPARTIDO por sala (anclado al reloj del servidor) --- // running 0|1 · duration (s) · remaining (s, cuando NO corre) · deadline (epoch s, cuando corre). // El admin (y cualquier jugador) lo controla; todas las pantallas ven la misma cuenta. const TIMER_DEFAULT = { running: 0, duration: 900, remaining: 900, deadline: 0 }; function buildTimer(t) { const x = (t && typeof t === 'object') ? t : {}; const dur = Math.max(1, Math.min(36000, Math.floor(Number(x.duration)) || 900)); let rem = Number(x.remaining); rem = Number.isFinite(rem) ? rem : dur; return { running: (x.running === 1 || x.running === true) ? 1 : 0, duration: dur, remaining: Math.max(0, Math.min(dur, Math.floor(rem))), deadline: Math.max(0, Math.floor(Number(x.deadline)) || 0), }; } // segundos a mostrar (offsetSec = serverNow - clientNow, para anular desfase de reloj) function timerSeconds(t, offsetSec) { if (!t) return 0; if (t.running) return Math.max(0, Math.round(t.deadline - (Date.now() / 1000 + (offsetSec || 0)))); return t.remaining; } // aplica un comando localmente (nowAdj = reloj del servidor estimado, en s) function applyTimerCmd(t, cmd, value, nowAdj) { t = buildTimer(t); if (cmd === 'set') { const d = Math.max(1, Math.min(600, value | 0)) * 60; return { running: 0, duration: d, remaining: d, deadline: 0 }; } if (cmd === 'start') { if (t.running || t.remaining <= 0) return t; return { running: 1, duration: t.duration, remaining: t.remaining, deadline: Math.round(nowAdj + t.remaining) }; } if (cmd === 'pause') { if (!t.running) return t; return { running: 0, duration: t.duration, remaining: Math.max(0, Math.round(t.deadline - nowAdj)), deadline: 0 }; } if (cmd === 'reset') { return { running: 0, duration: t.duration, remaining: t.duration, deadline: 0 }; } return t; } // URL del hub WebSocket (mismo host, /ws). Se evalúa en runtime (no en carga) para // no referenciar `location` en entornos de test sin DOM (parser_test). function wsUrl() { return (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws'; } function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const saved = useRef(loadStore()).current; // ---- estado de SALA ---- const [roomCode, setRoomCode] = useState(saved.roomCode || null); const [online, setOnline] = useState(true); const [codeInput, setCodeInput] = useState(''); const [lobbyBusy, setLobbyBusy] = useState(false); const [lobbyError, setLobbyError] = useState(''); const localRev = useRef(0); // última rev aplicada del servidor const lastSyncedSig = useRef(null); // firma del estado que el servidor ya conoce (anti-eco) // ---- estado del JUEGO (sincronizado por la sala) ---- const [view, setView] = useState('monumentos'); const [monument, setMonument] = useState(null); const [node, setNode] = useState(null); const [sprint, setSprint] = useState(1); // trackers por monumento: { coliseo:{MAT,ENE,SIS,TC}, stonehenge:{...}, ... } const [statsByMonument, setStatsByMonument] = useState(defaultStatsByMonument); // de la carta visible se comparten SOLO su id + la resolución; el mazo se baja local const [cardId, setCardId] = useState(null); const [resolved, setResolved] = useState(null); // cartas ya USADAS por este equipo, por contexto (deck:monumento:sprint). Compartido // por sala: una carta usada no vuelve a salir en ese sprint (LWW como el resto). const [usedCards, setUsedCards] = useState({}); const [deckState, setDeckState] = useState({ loading: false, error: null, cards: [] }); const deckCache = useRef({}); // key -> array de cartas (evita repetir fetch) const deckInflight = useRef({}); // key -> promesa en vuelo (deduplica peticiones) const reqToken = useRef(0); // descarta deckState (mazo a mostrar) obsoleto const drawToken = useRef(0); // descarta robos (cardId) obsoletos const navTimer = useRef(null); // (defensivo) cancela saltos diferidos al tablero si los hubiera const didInteract = useRef(false); // ¿hubo ya alguna acción local? (no pisar al rehidratar) const usedCardsRef = useRef(usedCards); // espejo de usedCards para leerlo en el draw asíncrono // ---- cronómetro COMPARTIDO por sala (lo controla el admin y/o los jugadores) ---- const [timer, setTimer] = useState(TIMER_DEFAULT); const [timerOpen, setTimerOpen] = useState(false); const [, setTick] = useState(0); // re-render por segundo mientras corre const clockOffset = useRef(0); // serverNow - clientNow (s), para la cuenta atrás // -------- helpers (no hooks) -------- const clearNav = () => { if (navTimer.current) { clearTimeout(navTimer.current); navTimer.current = null; } }; const currentCard = () => (cardId && deckState.cards) ? (deckState.cards.find((c) => c.id === cardId) || null) : null; // carga el mazo: caché + deduplicación de peticiones en vuelo (clave deck:monumento:sprint), // así el efecto de "mazo a mostrar" y un "robar" simultáneos comparten UN solo fetch. // No toca deckState (de eso se encarga el efecto); solo devuelve las cartas. const fetchDeck = (deck, monId, spr) => { const key = deckKey(deck, monId, spr); if (deckCache.current[key]) return Promise.resolve(deckCache.current[key]); if (deckInflight.current[key]) return deckInflight.current[key]; const p = fetch(deckUrl(deck, monId, spr)) .then((r) => r.json()) .then((d) => { delete deckInflight.current[key]; if (d && d.error) throw new Error(d.error); const cards = (d && d.cards) || []; deckCache.current[key] = cards; return cards; }) .catch((e) => { delete deckInflight.current[key]; throw e; }); deckInflight.current[key] = p; return p; }; // clave de contexto de un mazo (deck:monumento:sprint) — así se agrupan las cartas // usadas: DECIDE por monumento+sprint; VÓRTICE/EXPLORA por sprint. const ctxKeyFor = (nodeObj, mon, spr) => { const deck = nodeObj ? deckForNode(nodeObj.id) : null; return deck ? deckKey(deck, mon && mon.id, spr) : null; }; // elegir carta (ACCIÓN del usuario): saca la SIGUIENTE carta no usada al azar y // comparte su id. Sin repeticiones dentro del sprint; si se agotan, cardId=null // (el mazo queda exhausto). Solo el que actúa elige; los demás la ven por sync. const drawCard = (nodeObj, mon, spr) => { clearNav(); const deck = nodeObj ? deckForNode(nodeObj.id) : null; if (!deck) { setCardId(null); setResolved(null); return; } const ctxKey = deckKey(deck, mon && mon.id, spr); const my = ++drawToken.current; fetchDeck(deck, mon && mon.id, spr).then((cards) => { if (my !== drawToken.current) return; const used = (usedCardsRef.current && usedCardsRef.current[ctxKey]) || []; const picked = pickCard(cards, used); setCardId(picked ? picked.id : null); setResolved(null); }).catch(() => { if (my === drawToken.current) { setCardId(null); setResolved(null); } }); }; // marca una carta como USADA en su contexto (deck:monumento:sprint). Idempotente. const markUsed = (ctxKey, id) => { if (!ctxKey || !id) return; setUsedCards((prev) => { const cur = (prev && prev[ctxKey]) || []; if (cur.indexOf(id) !== -1) return prev; return Object.assign({}, prev, { [ctxKey]: cur.concat([id]) }); }); }; // aplica deltas {MAT,ENE,SIS,TC} a los trackers DEL MONUMENTO ACTIVO, acotados a [0, max] const applyDeltas = (deltas) => { if (!deltas || !monument) return; const mid = monument.id; setStatsByMonument((all) => { const next = Object.assign({}, all[mid] || STATS_DEFAULT); STAT_DEFS.forEach((d) => { const dv = deltas[d.id] || 0; if (dv) next[d.id] = Math.max(0, Math.min(d.max, (next[d.id] || 0) + dv)); }); return Object.assign({}, all, { [mid]: next }); }); }; // DECIDE: al elegir A/B se aplica su efecto a los trackers y se marca resuelta. // NO se salta al tablero automáticamente: la carta queda visible con los puntos // que suma/resta YA revelados, hasta que se pulsa «Continuar» (continueToBoard). // Como la vista es estado compartido, ese salto lleva a TODA la sala al tablero // (espejo). Todo va al estado compartido. const chooseOption = (letter) => { const card = currentCard(); if (!card || resolved) return; const opt = card[letter.toLowerCase()] || {}; const deltas = { MAT: opt.mat, ENE: opt.ene, SIS: opt.sis, TC: opt.tc }; applyDeltas(deltas); setResolved({ choice: letter, applied: deltas }); markUsed(ctxKeyFor(node, monument, sprint), card.id); // no volverá a salir este sprint }; // VÓRTICE/EXPLORA: aceptar aplica su efecto a los trackers automáticamente const acceptCard = () => { const card = currentCard(); if (!card || resolved || !node) return; let res; if (node.id === 'VOR') { const eff = parseEffectDeltas(card.efectoCompleto); applyDeltas(eff.deltas); res = { applied: eff.deltas, note: eff.note }; } else { // EXPLORA: profecía, sin efecto mecánico sobre los trackers res = { applied: {}, note: 'Carta en juego' }; } setResolved(res); markUsed(ctxKeyFor(node, monument, sprint), card.id); // no volverá a salir este sprint }; // ajuste MANUAL de un tracker del monumento activo (p.ej. recibir materia de otro // equipo). ±delta, acotado; se sincroniza como el resto del estado. const adjustStat = (statId, delta) => { applyDeltas({ [statId]: delta }); }; // -------- sincronización de sala -------- const hydrate = (st) => { setView(st.view || 'monumentos'); setMonument(st.monumentId ? (MONUMENTS.find((m) => m.id === st.monumentId) || null) : null); setNode(st.nodeId ? (NODE_TYPES.find((n) => n.id === st.nodeId) || null) : null); setSprint(Math.min(MAX_SPRINT, Math.max(1, st.sprint || 1))); setStatsByMonument(buildStatsByMonument(st.statsByMonument)); setTimer(buildTimer(st.timer)); setCardId(st.cardId || null); setResolved(st.resolved || null); setUsedCards(buildUsedCards(st.usedCards)); }; const applyRemoteState = (st, rev) => { localRev.current = rev || 0; lastSyncedSig.current = roomStateSig(buildRoomState(st.view, st.monumentId, st.nodeId, st.sprint, st.statsByMonument, st.timer, st.cardId, st.resolved, st.usedCards)); clearNav(); ++drawToken.current; // descarta cualquier robo local en vuelo (no debe pisar el estado remoto) ++reqToken.current; // descarta cargas de mazo en vuelo (el efecto recargará el mazo correcto) hydrate(st); }; // adopta estado remoto SOLO si es genuinamente nuevo (rev mayor a la ya aplicada) // y no es nuestro propio estado recién guardado (anti-eco). Así un eco del PULL no // pisa cambios locales con una lectura vieja ni cancela un salto en curso. const maybeAdopt = (d) => { if (!d || !d.state || typeof d.rev !== 'number') return; if (d.rev <= localRev.current) return; // ya aplicado o lectura obsoleta const incomingSig = roomStateSig(buildRoomState(d.state.view, d.state.monumentId, d.state.nodeId, d.state.sprint, d.state.statsByMonument, d.state.timer, d.state.cardId, d.state.resolved, d.state.usedCards)); if (incomingSig === lastSyncedSig.current) { localRev.current = d.rev; return; } // es nuestro estado applyRemoteState(d.state, d.rev); }; const enterRoom = (code, st, rev) => { applyRemoteState(st, rev || 1); setRoomCode(code); setCodeInput(''); setLobbyError(''); setLobbyBusy(false); setOnline(true); }; const leaveRoom = (gone) => { clearNav(); localRev.current = 0; lastSyncedSig.current = null; setRoomCode(null); setView('monumentos'); setMonument(null); setNode(null); setSprint(1); setStatsByMonument(defaultStatsByMonument()); setCardId(null); setResolved(null); setUsedCards({}); setDeckState({ loading: false, error: null, cards: [] }); setLobbyError(gone ? 'La sala ya no existe. Crea o entra en otra.' : ''); }; const createRoom = () => { setLobbyBusy(true); setLobbyError(''); roomCreate().then((d) => { noteServerNow(d); if (d && d.code && d.state) enterRoom(d.code, d.state, d.rev); else { setLobbyBusy(false); setLobbyError((d && d.error) || 'No se pudo crear la sala'); } }).catch(() => { setLobbyBusy(false); setLobbyError('Error de red'); }); }; const joinRoom = () => { const code = (codeInput || '').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8); if (code.length < 4) { setLobbyError('Introduce el código completo'); return; } setLobbyBusy(true); setLobbyError(''); roomGet(code, -1).then((d) => { noteServerNow(d); if (d && d.notFound) { setLobbyBusy(false); setLobbyError('No existe ninguna sala con ese código'); return; } if (d && d.state) enterRoom(code, d.state, d.rev); else { setLobbyBusy(false); setLobbyError((d && d.error) || 'No se pudo entrar en la sala'); } }).catch(() => { setLobbyBusy(false); setLobbyError('Error de red'); }); }; // ancla el reloj con el `now` del servidor (anula desfase para la cuenta atrás) const noteServerNow = (d) => { if (d && typeof d.now === 'number') clockOffset.current = d.now - Date.now() / 1000; }; // refresca el estado desde el servidor por la vía probada (rev-guard + anti-eco). // Lo usan tanto el polling como el "nudge" del WebSocket. Devuelve la promesa. const pullNow = () => { if (!roomCode) return Promise.resolve(); return roomGet(roomCode, localRev.current).then((d) => { setOnline(true); noteServerNow(d); if (d && d.notFound) { leaveRoom(true); return; } if (d && d.changed) maybeAdopt(d); else if (d && typeof d.rev === 'number' && d.rev > localRev.current) localRev.current = d.rev; }).catch(() => setOnline(false)); }; // comando de cronómetro: muta el timer COMPARTIDO (se sincroniza a toda la sala) const timerCmd = (cmd, value) => { setTimer((tm) => applyTimerCmd(tm, cmd, value, Date.now() / 1000 + clockOffset.current)); }; // -------- efectos -------- // al montar con una sala guardada: rehidrata su estado desde el servidor useEffect(() => { if (!roomCode) return; roomGet(roomCode, -1).then((d) => { noteServerNow(d); if (d && d.notFound) { leaveRoom(true); return; } if (!didInteract.current) maybeAdopt(d); // no pisar si el usuario ya empezó a jugar setOnline(true); }).catch(() => setOnline(false)); }, []); // PUSH: cuando el estado del juego cambia por una acción local, lo guarda (LWW) useEffect(() => { if (!roomCode) return; const st = buildRoomState(view, monument && monument.id, node && node.id, sprint, statsByMonument, timer, cardId, resolved, usedCards); const sig = roomStateSig(st); if (sig === lastSyncedSig.current) return; // venía del servidor o sin cambio real didInteract.current = true; // hubo un cambio LOCAL genuino lastSyncedSig.current = sig; // optimista: corta el bucle de eco const id = setTimeout(() => { roomSave(roomCode, st).then((d) => { if (d && d.notFound) { leaveRoom(true); return; } if (d && typeof d.rev === 'number') localRev.current = Math.max(localRev.current, d.rev); setOnline(true); }).catch(() => setOnline(false)); }, 120); return () => clearTimeout(id); }, [roomCode, view, monument, node, sprint, statsByMonument, timer, cardId, resolved, usedCards]); // espejo de usedCards en un ref, para leer las cartas usadas dentro del draw asíncrono useEffect(() => { usedCardsRef.current = usedCards; }, [usedCards]); // PULL: sondea el servidor como BASE robusta (el WebSocket solo acelera). Si hay // rev nueva, adopta el estado remoto (espejo). Auto-encadenado para no solapar. useEffect(() => { if (!roomCode) return; let alive = true; let to = null; const tick = () => { pullNow().finally(() => { if (alive) to = setTimeout(tick, POLL_MS); }); }; to = setTimeout(tick, POLL_MS); return () => { alive = false; if (to) clearTimeout(to); }; }, [roomCode]); // NUDGE por WebSocket (acelerador sobre el polling): al recibir aviso de cambio // de NUESTRA sala, refresca al instante por la vía probada. Si no hay WS (o cae), // el polling de arriba sigue funcionando -> el juego nunca se rompe. useEffect(() => { if (!roomCode || typeof WebSocket === 'undefined') return; let ws = null, alive = true, reconn = null, fails = 0; const open = () => { try { ws = new WebSocket(wsUrl()); } catch (e) { schedule(); return; } ws.onopen = () => { fails = 0; try { ws.send(JSON.stringify({ type: 'sub', code: roomCode })); } catch (e) {} }; ws.onmessage = (ev) => { let m = null; try { m = JSON.parse(ev.data); } catch (e) { return; } if (m && m.type === 'changed' && m.code === roomCode) { if (typeof m.rev === 'number' && m.rev >= 0 && m.rev <= localRev.current) return; // ya tenemos esa rev pullNow(); } }; ws.onclose = () => { if (alive) schedule(); }; ws.onerror = () => { try { ws.close(); } catch (e) {} }; }; const schedule = () => { fails++; clearTimeout(reconn); reconn = setTimeout(open, Math.min(8000, 800 * fails)); }; open(); return () => { alive = false; clearTimeout(reconn); if (ws) { try { ws.onclose = null; ws.close(); } catch (e) {} } }; }, [roomCode]); // baja el mazo a mostrar cuando se entra en vista de cartas (también para // quien solo mira). Único responsable de deckState; reqToken descarta el // resultado si el contexto (casilla/monumento/sprint) cambió entretanto. useEffect(() => { if (!roomCode || view !== 'cartas' || !node) return; const deck = deckForNode(node.id); if (!deck) { setDeckState({ loading: false, error: null, cards: [] }); return; } const key = deckKey(deck, monument && monument.id, sprint); const token = ++reqToken.current; if (deckCache.current[key]) { setDeckState({ loading: false, error: null, cards: deckCache.current[key] }); return; } setDeckState({ loading: true, error: null, cards: [] }); fetchDeck(deck, monument && monument.id, sprint) .then((cards) => { if (token === reqToken.current) setDeckState({ loading: false, error: null, cards: cards }); }) .catch(() => { if (token === reqToken.current) setDeckState({ loading: false, error: 'Error de red', cards: [] }); }); }, [roomCode, view, node, monument, sprint]); // «Continuar» tras resolver una carta DECIDE: la carta se queda visible (con los // puntos revelados) y solo al pulsar se vuelve al tablero. Al ser `view` estado // compartido, el salto se propaga a toda la sala (espejo). clearNav cancela // cualquier salto diferido heredado por robustez. const continueToBoard = () => { clearNav(); setView('tablero'); }; // tick local: mientras el cronómetro corre, re-renderiza cada segundo para que // la cuenta atrás avance suave (el estado del timer NO cambia cada segundo). useEffect(() => { if (!timer || !timer.running) return; const id = setInterval(() => { // al expirar: normaliza a PARADO (corrige el estado y propaga); si no, re-renderiza if (timerSeconds(timer, clockOffset.current) <= 0) { setTimer((tm) => (tm && tm.running) ? { running: 0, duration: tm.duration, remaining: 0, deadline: 0 } : tm); } else { setTick((x) => (x + 1) % 1000000); } }, 1000); return () => clearInterval(id); }, [timer]); // persistencia LOCAL: solo el código de sala (el juego y el cronómetro viven en el servidor) useEffect(() => { try { localStorage.setItem(STORE_KEY, JSON.stringify({ roomCode: roomCode })); } catch (e) {} }, [roomCode]); // ---- lobby: crear o entrar a una sala (no se renderiza el juego sin sala) ---- if (!roomCode) { return (
{ setCodeInput(v); if (lobbyError) setLobbyError(''); }} onJoin={joinRoom} busy={lobbyBusy} error={lobbyError} />
); } const card = currentCard(); // trackers del monumento activo (cada monumento tiene los suyos) const stats = (monument && statsByMonument[monument.id]) || STATS_DEFAULT; // cronómetro compartido: segundos a mostrar (anclados al reloj del servidor) const timeLeft = timerSeconds(timer, clockOffset.current); const timerRunning = !!(timer && timer.running); // ¿mazo exhausto? (todas las cartas del contexto ya usadas por este equipo) const activeCtxKey = ctxKeyFor(node, monument, sprint); const usedForCtx = (activeCtxKey && usedCards[activeCtxKey]) || []; const deckCount = deckState.cards ? deckState.cards.length : 0; const exhausted = !!activeCtxKey && !deckState.loading && !deckState.error && deckCount > 0 && !card && usedForCtx.length >= deckCount; return (
leaveRoom(false)} /> {view === 'monumentos' && {setMonument(m);setView('tablero');}} /> } {view === 'tablero' && setSprint((s) => Math.min(MAX_SPRINT, Math.max(1, s + d)))} timeLeft={timeLeft} timerRunning={timerRunning} onOpenTimer={() => setTimerOpen(true)} onPickNode={(n) => {setNode(n);setView('cartas');setResolved(null);setCardId(null);drawCard(n, monument, sprint);}} onAdjustStat={adjustStat} onBack={() => setView('monumentos')} showLinks={!!t.showLinks} /> } {/* timeLeft/timerRunning derivan del cronómetro COMPARTIDO de la sala */} {view === 'cartas' && { clearNav(); setView('tablero'); }} /> } timerCmd(timerRunning ? 'pause' : 'start')} onReset={() => timerCmd('reset')} onSetMinutes={(min) => timerCmd('set', min)} onClose={() => setTimerOpen(false)} /> setTweak('showLinks', v)} />
); } ReactDOM.createRoot(document.getElementById('root')).render();