Capítulo salas de la guía Neblla — salas, lobby, gathering y emparejamiento. Las salas sirven igual a una partida que a una app colaborativa (una pizarra compartida, un documento entre varios, un chat). El contrato base está en core.txt; el criterio de cuándo usar Neblla, en empezar.txt. # Salas y emparejamiento ## El lobby Todo usuario conectado aterriza en la sala `lobby`. Quién hay y quién soy: - `neblla.room.users` — array de usuarios de la sala actual. - `neblla.room.me` — tú en esta sala (el mismo objeto que sale en `neblla.room.users`). Tu id en la app es `neblla.auth.me.id`. `room.on('userJoined')` se dispara una vez por cada usuario que ya estaba al llegar y otra vez por cada entrada nueva; el flag `alreadyPresent` los distingue: ```javascript neblla.room.on('userJoined', event => { if (event.alreadyPresent) { // ya estaba — añadirlo a la lista en silencio } else { // acaba de entrar — sonido, animación } }); ``` ```javascript neblla.room.on('userLeft', event => { // event.user se ha ido — quitarlo de la lista }); ``` El lobby es buen momento para publicar el perfil visible del usuario: ```javascript neblla.db.setPublicInfo({ name: 'John', color: '#ff8800' }); ``` Queda en `user.publicInfo` para todos los de la misma sala y se restaura en reconexiones. Cuando alguien lo actualiza, todos reciben el evento `room.on('publicInfo')`: ```javascript neblla.room.on('publicInfo', event => { // event.user.publicInfo tiene los nuevos datos }); ``` Render de lista de usuarios: ```javascript const renderPlayers = () => { neblla.room.users.forEach(user => { console.log(user.publicInfo?.name || 'Guest', user.isPresenter ? '(host)' : '', user.isMe ? '(you)' : ''); }); }; neblla.room.on('userJoined', renderPlayers); neblla.room.on('userLeft', renderPlayers); ``` ## Salas con nombre (estilo chat) Mismo nombre de sala = mismo grupo. Un usuario solo puede estar en una sala a la vez. ```javascript neblla.rooms.join('eurovision'); neblla.message.send({ type: 'msg', text: '¡me encanta esta canción!' }); ``` Los mensajes solo llegan a quien esté dentro. Cambiar de sala: `rooms.join('otra')`. Volver al lobby: `room.leave()`. Por defecto cualquier usuario crea salas con solo hacer `rooms.join('nombre-nuevo')`. Para limitar la app a una lista fija de salas válidas: developers.neblla.com → Mis apps → tu app → Funciones › Salas. ## Tipos de sala La política se elige por app desde el dashboard: - **`ephemeral`** (default) — existe solo mientras hay alguien conectado; desaparece cuando se va el último usuario. Sin DB, sin historial: quien entra después no ve lo escrito antes. - **`public`** — persistida. Cualquier usuario de la app puede entrar. Historial de chat guardado en la base de datos de la app (ver «Historial de chat»). Sobrevive a reinicios. Foros, lounges, lobbies públicos. - **`private`** — persistida con whitelist de personas. Solo miembros entran. Los *owners* (rol intra-sala) invitan, expulsan, promueven a otros owners y borran mensajes. Si el owner abandona, el rol pasa al miembro más antiguo. Si la whitelist queda vacía, la sala se borra. Guilds, salas privadas, DMs grupales. Una misma app puede mezclar tipos: las salas nacen `ephemeral` y se promueven con `room.persist`. **Cuándo se borra una sala persistida.** Una vez por hora Neblla borra las salas `public` y `private` que llevan demasiado tiempo sin actividad (nadie escribe en el chat, cambia sus `data` ni sus miembros), con su chat y sus datos; quien estuviera dentro vuelve al lobby con `rooms.on('deleted')` y `reason: 'inactive'`. El plazo es de la app, por tipo: 30 días las públicas y 90 las privadas, `0` = nunca (`neblla_update_app` con `rooms: { public: { ttlInactivity }, private: { ttlInactivity } }`). Cada sala puede llevar **su propio plazo**, que manda sobre el de su tipo: `room.persist({ name, type, ttlInactivity })` al crearla, en días enteros de 0 a 3650, y `0` = no se borra nunca. Así, en un ajedrez, las partidas privadas se van a los 90 días y la conversación privada entre Ana y Luis se queda: ```javascript const name = await neblla.rooms.book(); await neblla.room.persist({ name, type: 'private', ttlInactivity: 0 }); // no se borra nunca ``` Un valor que no es un entero de 0 a 3650 se rechaza con `invalid_ttl`. Desde el cliente, una sala `public` dura como mucho el plazo de la app para las públicas: con 30 días, vale de 1 a 30, y `0` o 31 se rechazan con `invalid_ttl`, `min: 1` y `max: 30` (si el plazo de la app es `0`, vale cualquiera). Que una sala pública no se borre nunca lo decide el dev (`neblla_create_room`, `neblla_update_room` o el panel); las privadas, como la conversación de dos de arriba, sí pueden pedir `0`. `rooms.get` y `rooms.list` lo devuelven en `ttlInactivity` (`null`: la sala sigue el plazo de su tipo). El dev lo cambia o lo quita después con `neblla_update_room` (`ttlInactivity: null` la devuelve al plazo de su tipo), y lo pone en las salas predefinidas con `neblla_create_room`. **Salas solo para usuarios con login.** `neblla.rooms.book({ config, requireLogin: true })` —o `requireLogin` en la política de salas de la app, para todas— deja fuera a los invitados: al intentar entrar reciben `login_required`, y tu app puede ofrecerles `neblla.auth.login()`. Las salas `private` y la persistencia lo exigen siempre. ## Partidas: rooms.book → gathering → room.start Una partida necesita reservar espacio, fijar reglas, esperar jugadores y empezar: ```javascript const roomId = await neblla.rooms.book({ config: { tiempoMax: 180, modo: 'blitz' } }); ``` - `config` es un JSON abierto del desarrollador. Neblla no lo interpreta. Se consulta con `neblla.room.config` (también dentro de `server/gathering.js` y `server/room.js`). - **Una sala con comportamiento propio** (un modo boss, un torneo, una sala de práctica) no necesita otro fichero: reserva la sala con su modo en `config` y haz que `room.js` se comporte según `neblla.room.config`. - La sala nace en estado `gathering` y es visible desde el lobby vía `neblla.rooms.gathering`: array de salas en formación, cada una con `{ name, config, users }`, refrescado por el evento `rooms.on('gathering')`. ```javascript // Desde el lobby, listar las partidas abiertas: neblla.rooms.gathering.forEach(room => { console.log(room.name, room.users.length, room.config); }); ``` - Los jugadores entran con `rooms.join(roomId)`. Cuando están todos se llama a `neblla.room.start()` — desde un cliente **solo puede hacerlo el presentador** (y ni él si las reglas sin código tienen `allowPresenterStart: false`, «El presentador puede empezar antes» apagado en el panel: responde `{ error: 'not_authorized', reason: 'presenter_start_off' }`), y `server/gathering.js` también puede: la sala pasa a `running`, el server arranca y funcionan `message.to('server', …)` y lo que manda el server. - Desde Funciones › Salas (en el panel) se limita el máximo de jugadores por sala y si se permite entrar como espectador a la partida ya empezada. Vía MCP ese tope es `neblla_update_app` con `matchmaking: { maxPlayers }` (0 = sin límite, hasta 1000) — no `rooms`, ni el `config` de `rooms.book`. Flujo correcto: ```javascript // El nombre de la sala lo genera el servidor — viene en el ack de rooms.book. // `config` es JSON tuyo: Neblla NO lo interpreta. Este maxPlayers no limita // nada por sí solo — lo lees en room.js y lo haces cumplir tú. El tope real // que aplica Neblla se configura aparte (matchmaking.maxPlayers). const roomName = await neblla.rooms.book({ config: { maxPlayers: 4 } }); await neblla.rooms.join(roomName); // usuarios entran, esperan, se llena neblla.room.start(); ``` `rooms.book` no acepta un nombre de cliente — el id de sala siempre lo elige el servidor para evitar colisiones. Sí puedes pedir su longitud: `rooms.book({ length: 3 })` da un código de 3 caracteres (3–12, por defecto 5; alfabeto sin 0/O/1/I/L). **Salas por invitación.** `neblla.rooms.book({ config, guests: [id, …] })` reserva una partida a la que solo entran los de la lista (y quien la reservó) mientras exista, también ya empezada; cualquier otro recibe `rooms.on('joinError')` con `code: 'not_invited'`, y la sala no sale en `neblla.rooms.gathering`. Cada entrada de `guests` es el `id` en la app de un usuario de esta app (24 hex; uno que no lo es da `invalid_guests` con la lista `unknown`) o el `id` de sesión de alguien conectado ahora (`neblla.room.users[i].id`). Los conectados ahora reciben `rooms.on('invite')` con `type: 'guests'` y `roomName`; a los demás pásales el código o el enlace. Hasta 100; pide login (`login_required`) y la sala exige login para entrar. Errores del ack: `{ error: 'login_required' }`, `{ error: 'invalid_guests' }`. **Jugar ya.** `const name = await neblla.rooms.play({ config? })` mete al usuario en la sala en formación más llena que aún tenga plaza bajo `matchmaking.maxPlayers` y el mismo `config`, o en una nueva; así los siguientes N llenan la siguiente sala. Las salas por invitación quedan fuera. Si el usuario ya está en una sala (que no sea el lobby), devuelve esa: `play()` nunca le saca de una partida. El `config` se compara sin importar el orden de sus claves. Rechaza con el motivo de la última sala que no le dejó entrar (`login_required` si la app pide login para sus salas, `quota_exceeded`…) o `room_unavailable`. **Compartir una sala.** `neblla.rooms.link(name?)` da la URL de esta página con `?room=` (por defecto, la sala actual; en el lobby, `null`); `neblla.rooms.qr(name?, { size, dark })` da la URL de un SVG con su QR que sirve Neblla, para un ``; `neblla.rooms.linked()` da el código con el que se abrió la página (o `null`), para entrar directamente: ```javascript const code = neblla.rooms.linked(); if (code) await neblla.rooms.join(code); img.src = neblla.rooms.qr(neblla.room.name, { size: 160 }); ``` ## Gathering configurado vs custom `server/gathering.js` decide *cuándo* arranca la sala (la partida, la sesión). Para los casos comunes no hace falta código: desde Funciones › Salas (en el panel) se configuran reglas declarativas — empezar con un mínimo de usuarios, cuando pasen X segundos desde la última entrada, cuando llegue un mensaje específico (botón "Empezar" del anfitrión), o combinaciones. Escribe `gathering.js` solo si la lógica de arranque es más compleja. Vía MCP esas reglas son `neblla_update_app` con `matchmaking: { mode, rule, minPlayers, maxPlayers, waitSeconds, allowPresenterStart }` — `rule` es `"count"` (se alcanza minPlayers), `"timer"` (waitSeconds desde la última entrada), `"count_then_timer"` o `"button"` (un mensaje del anfitrión). Los valores por defecto son `mode: "nocode"`, `rule: "count_then_timer"`, `minPlayers: 2`, `maxPlayers: 0` (sin límite), `waitSeconds: 60`, `allowPresenterStart: true`; una app nueva empieza con esos valores, y su `gathering.js` es el que sale de esas reglas (cada receta de «Recetas: tres formas de sala», abajo, dice con qué regla arranca). Con `"button"`, el `neblla.message.to('server', { type: 'start' })` de cualquier jugador arranca la sala; con las demás reglas y `allowPresenterStart: true`, el `gathering.js` generado solo acepta ese mensaje del presentador y lo ignora si viene de otro. **El `mode` importa: `"nocode"` (el de por defecto) REGENERA `server/gathering.js` a partir de estas reglas cada vez que se guardan; `"custom"` significa que el fichero es tuyo y Neblla no lo toca nunca.** Así que, si vas a escribir `gathering.js` a mano, pon antes `mode: "custom"`: con `"nocode"`, el siguiente guardado de las reglas lo sustituye. `server/lobby.js` igual: solo si hay decisiones reales en el lobby (matchmaking inteligente, cola de espera, agrupación por nivel). Patrón de matchmaking: ```javascript // lobby.js (corre en Fog: lo elige Neblla, y puede coincidir con el presentador) const queue = []; // Un fichero de server oye en message.on lo que los clientes mandan con // message.to('server', …) (m.to === 'server'). Nunca oye lo de // message.to('presenter', …): eso es privado del cliente cuyo isPresenter es // true (el rol de presentador es distinto del de servidor). neblla.message.on(m => { if (m.to !== 'server') return; if (m.data.type === 'ready') { queue.push(m.from); if (queue.length >= 4) { neblla.rooms.book().then(roomId => { const players = queue.splice(0, 4); neblla.message.send({ type: 'match-found', roomId, players }); }); } } }); // client.js neblla.message.to('server', { type: 'ready' }); // esto es lo que te mete en la cola // Lo que manda el server llega a message.on con m.from === 'server' // (un broadcast entre pares, de cliente a cliente, trae el id de quien lo mandó). neblla.message.on(m => { if (m.from === 'server' && m.data.type === 'match-found' && m.data.players.includes(neblla.auth.me.id)) { neblla.rooms.join(m.data.roomId); } }); ``` ## API de salas persistentes `neblla.room.persist({ name, type, whitelist?, ttlInactivity? })` **(CLIENT)** — sube una sala recién reservada de `ephemeral` a `public` o `private`. `ttlInactivity`: los días sin actividad tras los que se borra, solo esta sala (`0` = nunca; ver «Cuándo se borra una sala persistida»). Hay que llamarlo entre `rooms.book` y el primer `rooms.join` — una vez alguien entra, el tipo queda fijado. La sala guarda lo que le dio `rooms.book` (`config`, la contraseña de máster y `requireLogin`) y sus `data` desde el primer momento: cuando vuelve después de quedarse vacía, vuelve igual. La whitelist (para `private`) bootstrappea miembros iniciales; el llamador se añade automáticamente como primer owner. Los ids de la whitelist son **per-app userIds** (los `id` de `neblla.room.users[i]`), no personIds — solo se resuelven contra usuarios online en esta app en ese momento. Los offline se añaden después vía `social.inviteToRoom`. `neblla.rooms.list({ type? })` **(CLIENT)** — lista salas persistentes accesibles. Cada entrada: `{ name, type, creator, memberCount, activeUsers, lastActivityAt }`. `neblla.rooms.get(name?)` **(CLIENT)** — inspecciona una sala por nombre (por defecto, la actual). `neblla.room.members(name?)` **(CLIENT)** — solo `private`. Devuelve `{ members[], invites[] }`. `neblla.rooms.delete(name?)` **(CLIENT)** — borra una sala persistente. Moderadores (cualquier sala) y owners de privadas (la suya). Echa a todos al lobby y emite `rooms.on('deleted')`. `neblla.rooms.setType(name, type)` **(CLIENT)** — promociona/baja entre `public` y `private`. Solo moderadores. Membresía de salas privadas: - `neblla.rooms.acceptInvite(roomName)` / `neblla.rooms.rejectInvite(roomName)` — responde a una invitación (`rooms.on('invite')`). - `neblla.room.kick(targetUserId)` — expulsa de la sala privada actual. Solo owner o moderador. - `neblla.room.abandon(roomName?)` — renuncia a la membresía. Distinto de `room.leave`: leave = "me desconecto ahora", abandon = "quítame de la whitelist". - `neblla.room.promote(targetUserId)` / `neblla.room.demote(targetUserId)` — gestión de owners. Solo owner. No puedes auto-demotearte si eres el último. - `neblla.social.inviteToRoom(target)` — añade a la whitelist de tu sala privada. ## Roles de sala: master y board Los dos roles **requieren el módulo `room` encendido** (`neblla_update_app` con `features: { room: true }`); sin él las llamadas responden `feature_disabled`. Dos roles opcionales de cara a los usuarios. El **master** dirige la sesión; el **board** es el dispositivo de exhibición (la pantalla compartida, el tablero de la mesa). Ninguno tiene que ver con quién ejecuta los ficheros de `server/`, y tomarlos no arranca la sala. ```javascript neblla.room.setBoard(tabletUserId); // null lo limpia; es exclusivo neblla.room.transferMaster(otroJugadorId); neblla.room.on('board', ({ user, isMe }) => { /* … */ }); ``` Los reparte el master; mientras no haya master, también el presentador. Firmas completas y errores en referencia.txt. **Master con contraseña (opcional).** `neblla.rooms.book({ config, masterPassword: 'profe-1234' })` protege el rol: el presentador ya no puede repartirlo, y quien sepa la contraseña lo toma con `neblla.room.claimMaster('profe-1234')` — la profesora cuyo portátil se cayó, desde otro dispositivo. El master actual puede seguir pasándolo. La sala lo anuncia con `neblla.room.masterProtected`; la contraseña nunca viaja a nadie. Errores de `room.claimMaster`: `wrong_password`, `too_many_attempts` (cinco fallos seguidos bloquean un minuto), `no_master_password`. ## Recetas: tres formas de sala Una app con salas nace con un solo ejemplo: `server/room.js` pone el estado (`neblla.state.set`), cada cliente lo pinta (`neblla.state.on`) y le habla por `message` (`neblla.message.to('server', …)`). En el ejemplo, cada jugador suma puntos a un marcador. Casi todo se construye sobre esa forma; estas tres recetas son las que más se repiten. Cada una dice qué pone el servidor, qué hace cada cliente y cómo arranca la sala. El tablero (board) y el director (master) que se eligen al crear la app traen su propia pieza de ejemplo y sirven con cualquiera de las tres. ### Con árbitro (ajedrez, cartas, un quiz competitivo) El servidor tiene las reglas: valida cada jugada, lleva el turno y publica el estado bueno. Los clientes no cambian nada por su cuenta: mandan la jugada y pintan lo que vuelve. Es la forma del ejemplo, con reglas de verdad. - **El servidor** (`server/room.js`): el tablero, de quién es el turno y quién ha ganado, en el estado. Lo que no vale lo rechaza con un aviso solo a quien lo mandó. - **Cada cliente** (`client.js`): pinta el estado, deja jugar solo en su turno y manda la jugada al servidor. - **Arranque:** con los dos jugadores dentro: `neblla_update_app({ matchmaking: { rule: 'count', minPlayers: 2, maxPlayers: 2 } })`. ```javascript // server/room.js — el árbitro de un tres en raya const LINES = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]]; let s = neblla.state.get() || { board: Array(9).fill(null), players: neblla.room.users.slice(0, 2).map(u => u.id), turn: 0, // índice en players winner: null, // id del ganador, o 'draw' }; neblla.message.on((m) => { if (m.to !== 'server' || m.data?.type !== 'move') return; const reject = (reason) => neblla.message.to(m.from, { type: 'rejected', reason }); const cell = m.data.cell; if (s.winner) return reject('game_over'); if (s.players[s.turn] !== m.from) return reject('not_your_turn'); if (!Number.isInteger(cell) || cell < 0 || cell > 8 || s.board[cell]) return reject('bad_move'); s.board[cell] = s.turn === 0 ? 'X' : 'O'; if (LINES.some(l => l.every(i => s.board[i] === s.board[cell]))) s.winner = m.from; else if (s.board.every(Boolean)) s.winner = 'draw'; else s.turn = 1 - s.turn; neblla.state.set(s); }); // Si un jugador se va, gana el otro. neblla.room.on('userLeft', ({ user }) => { if (s.winner || !s.players.includes(user.id)) return; s.winner = s.players.find(id => id !== user.id); neblla.state.set(s); }); neblla.state.set(s); ``` ```javascript // client.js — dentro de window.onNeblla (con $ = id => document.getElementById(id), como en el // ejemplo); nueve botones con data-cell="0" … "8" y un #status const status = $('status'); const cells = [...document.querySelectorAll('[data-cell]')]; cells.forEach(b => b.onclick = () => neblla.message.to('server', { type: 'move', cell: Number(b.dataset.cell) })); neblla.state.on(paint); neblla.room.on('joined', ({ isLobby }) => { if (!isLobby && neblla.state.get()) paint(neblla.state.get()); }); neblla.message.on(({ from, data }) => { if (from === 'server' && data.type === 'rejected') status.textContent = 'No vale: ' + data.reason; }); function paint(s) { const myTurn = !s.winner && s.players[s.turn] === neblla.room.me?.id; cells.forEach((b, i) => { b.textContent = s.board[i] || ''; b.disabled = !myTurn || !!s.board[i]; }); status.textContent = s.winner === 'draw' ? 'Empate' : s.winner ? (s.winner === neblla.room.me?.id ? 'Has ganado' : 'Has perdido') : (myTurn ? 'Te toca' : 'Turno del otro'); } ``` **Cartas que no ve nadie más.** El estado lo ven todos. La mano de cada uno va aparte: `neblla.message.to(id, { type: 'hand', cards })` desde `room.js` llega solo a ese jugador. Si `room.js` corre en Fog, el dispositivo que hace de servidor podría leerlas; cuando eso importa, reparte con `neblla.tools.deck` («Baraja: quién tiene qué», en servidor.txt) o lleva la sala a Server. ### Pantalla y mandos (al estilo Jackbox) Una pantalla grande que todos miran (la tele, un portátil, un proyector) y cada jugador con su móvil como mando. La pantalla es el **board** (`neblla.room.setBoard`) o, mientras no haya, el presentador: el primer dispositivo que entra. - **El servidor** (`server/room.js`): la fase, la pregunta, quién ha respondido y el marcador, en el estado. Solo la pantalla puede pasar de ronda; las respuestas de un mando se aceptan una vez por ronda. - **La pantalla:** enseña el código de la sala y su QR para entrar desde el móvil («Compartir una sala», arriba); después, la pregunta, quién falta por responder y el marcador. Su botón pasa a la ronda siguiente. - **Cada mando:** cuatro botones; manda su respuesta al servidor y espera la siguiente ronda. - **Arranque:** la pantalla pulsa «Empezar» cuando están todos: `matchmaking: { rule: 'button', minPlayers: 1 }`. Ese botón manda `neblla.message.to('server', { type: 'start' })` y el `gathering.js` que sale de la regla arranca la sala. ```javascript // server/room.js — un quiz de cuatro opciones const QUESTIONS = [ { q: '¿Capital de Australia?', options: ['Sídney', 'Canberra', 'Melbourne', 'Perth'], right: 1 }, { q: '¿Cuántas patas tiene una araña?', options: ['6', '8', '10', '12'], right: 1 }, ]; let s = neblla.state.get() || { phase: 'waiting', round: -1, question: null, answered: [], scores: {} }; let answers = {}; // id → opción; fuera del estado, que lo ven todos const isScreen = (id) => neblla.room.board ? neblla.room.board.id === id : !!neblla.room.users.find(u => u.id === id)?.isPresenter; function next() { const round = s.round + 1; if (round >= QUESTIONS.length) { s = { ...s, phase: 'end', question: null }; return neblla.state.set(s); } const { q, options } = QUESTIONS[round]; // la correcta no viaja answers = {}; s = { ...s, phase: 'question', round, question: { q, options }, answered: [] }; neblla.state.set(s); } function reveal() { const right = QUESTIONS[s.round].right; for (const [id, a] of Object.entries(answers)) if (a === right) s.scores[id] = (s.scores[id] || 0) + 1; s = { ...s, phase: 'reveal', right }; neblla.state.set(s); } neblla.message.on((m) => { if (m.to !== 'server') return; if (m.data?.type === 'next' && isScreen(m.from)) return s.phase === 'question' ? reveal() : next(); if (m.data?.type === 'answer' && s.phase === 'question' && !(m.from in answers) && !isScreen(m.from)) { answers[m.from] = m.data.option; s = { ...s, answered: [...s.answered, m.from] }; neblla.state.set(s); } }); neblla.state.set(s); ``` ```javascript // client.js — dentro de window.onNeblla // #screen (con #screenCode, un para el QR, #screenQuestion, #screenScores y el botón // #screenNext) y #pad (cuatro botones // con data-option="0" … "3"): cada dispositivo enseña uno de los dos. let isLobby = true; const me = () => neblla.room.me; const isScreenUser = (u) => !!u && (u.isBoard || (!neblla.room.board && u.isPresenter)); const amScreen = () => !isLobby && isScreenUser(me()); neblla.room.on('joined', (e) => { isLobby = e.isLobby; if (!isLobby) $('screenCode').src = neblla.rooms.qr(neblla.room.name, { size: 200 }); show(); }); neblla.room.on('board', show); neblla.room.on('presenter', show); neblla.state.on(show); // La pantalla: «Empezar» antes de arrancar; después, pasar de ronda. $('screenNext').onclick = () => neblla.message.to('server', { type: neblla.room.phase === 'running' ? 'next' : 'start' }); // Los mandos: un botón por opción. document.querySelectorAll('[data-option]').forEach(b => b.onclick = () => neblla.message.to('server', { type: 'answer', option: Number(b.dataset.option) })); function show() { const s = neblla.state.get(); $('screen').hidden = !amScreen(); $('pad').hidden = isLobby || amScreen(); if (!s) return; if (amScreen()) { $('screenQuestion').textContent = s.question?.q || (s.phase === 'end' ? 'Fin' : 'Preparados…'); $('screenScores').textContent = neblla.room.users.filter(u => !isScreenUser(u)) .map(u => (u.publicInfo.name || u.id.slice(0, 6)) + ' ' + (s.scores[u.id] || 0) + (s.answered.includes(u.id) ? ' ✓' : '')).join(' · '); } else { const done = s.phase !== 'question' || s.answered.includes(me()?.id); document.querySelectorAll('[data-option]').forEach((b, i) => { b.textContent = s.question?.options[i] || ''; b.disabled = done; }); } } ``` `neblla.room.phase` dice si la sala está en `gathering` o `running`. Si la respuesta buena no debe estar ni en el dispositivo que hace de servidor (en Fog es el de un jugador), mira «Ejemplo: un quiz en clase» en servidor.txt: las correctas viven solo en la pantalla y las respuestas van en sobres. ### Todos a la vez (una pizarra, un documento) Nadie valida: cada uno dibuja y los demás lo ven al momento. Los trazos viajan entre los clientes por P2P (`neblla.message.send`, sin coste de cuota mientras va directo); el servidor solo guarda lo dibujado para quien llega tarde. - **El servidor** (`server/room.js`): la lista de trazos terminados, en el estado. Nada más. - **Cada cliente:** al mover el dedo pinta y manda el tramo a los demás; al soltar, manda el trazo entero al servidor. Pinta lo que llega de los otros, y al entrar, lo que ya estaba en el estado. - **Arranque:** con el primero que entra: `matchmaking: { rule: 'count', minPlayers: 1 }`. ```javascript // server/room.js — guarda la pizarra para los que llegan tarde let s = neblla.state.get() || { strokes: [] }; neblla.message.on((m) => { if (m.to !== 'server' || m.data?.type !== 'stroke') return; s.strokes.push(m.data.stroke); if (s.strokes.length > 300) s.strokes.shift(); // que no crezca sin fin neblla.state.set(s); }); neblla.state.set(s); ``` ```javascript // client.js — dentro de window.onNeblla; un const ctx = $('board').getContext('2d'); const color = '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0'); let stroke = null; function line(c, a, b) { ctx.strokeStyle = c; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(...a); ctx.lineTo(...b); ctx.stroke(); } function paintStroke(st) { for (let i = 1; i < st.points.length; i++) line(st.color, st.points[i - 1], st.points[i]); } $('board').onpointerdown = (e) => { stroke = { color, points: [[e.offsetX, e.offsetY]] }; }; $('board').onpointermove = (e) => { if (!stroke) return; const a = stroke.points[stroke.points.length - 1], b = [e.offsetX, e.offsetY]; stroke.points.push(b); line(color, a, b); neblla.message.send({ type: 'seg', color, a, b }); // a toda la sala, P2P }; $('board').onpointerup = () => { if (stroke) neblla.message.to('server', { type: 'stroke', stroke }); stroke = null; }; // Lo que dibujan los demás, al momento. neblla.message.on(({ data, fromMe, from }) => { if (fromMe || from === 'server' || data?.type !== 'seg') return; line(data.color, data.a, data.b); }); // Al entrar: lo que ya había. neblla.room.on('joined', ({ isLobby }) => { ctx.clearRect(0, 0, 800, 500); if (!isLobby) (neblla.state.get()?.strokes || []).forEach(paintStroke); }); ``` Si dos cambian lo mismo a la vez gana el último; para un texto editado entre varios, usa un CRDT (por ejemplo, Yjs) sobre los mismos mensajes. Y si lo que se guarda crece más allá de una sala, mira «Un espacio compartido más grande que una sala» en comunidad.txt. Sin salas separadas (un solo espacio para todos), el patrón es `lobby-only` (core.txt). ## room.data `neblla.room.setData(data)` **(CLIENT)** — cualquier miembro, o solo los moderadores si la sala los tiene. Fusiona `data` con `neblla.room.data` (persistente en salas `public`/`private`), clave a clave en el primer nivel: las claves que no mandas se quedan, y para vaciar una la mandas con `null`. El fichero de servidor no la tiene: lo que decide él va en el estado (`neblla.state.set`). Todos los miembros reciben `room.on('data')`. Sujeto al límite antiabuso `maxDataSize` (10 KB por defecto; lo ajustas o lo quitas en la configuración de salas); un exceso emite `room.on('dataError')`. En una sala persistente, Neblla la tiene en memoria y la escribe en la base de datos en los momentos clave (la sala se vacía o se cierra, y como mucho cada 3 minutos; ver «Lo que no es del usuario» en identidad.txt); `room.setData(data, { now: true })` la escribe ya, para lo que no puede esperar (el final de una partida); una sala escribe así una vez cada 10 segundos como mucho, y si llegan más, la última se escribe al acabar ese plazo. **Si tu documento compartido crece**, mira "Un espacio compartido más grande que una sala" en comunidad.txt: una entry por persona, sin conflictos de escritura, y la sala se queda solo con el aviso de que algo cambió. ## Historial de chat (salas persistentes) `neblla.room.history({ name?, before?, limit? })` **(CLIENT)** — paginación del chat persistido. Newest-first. Default limit 50, max 200. Los mensajes borrados vienen con `body: null` y `deleted: true`. **Dónde se guarda.** El chat es un dato más de la app: va a su base de datos y cuenta en su tope (50 MB por cuenta gratis, compartidos por sus apps; 100 MB por proyecto Pro), junto a los guardados y las tablas. El panel (Config → Capacidad) muestra cuánto de esa base de datos es chat. Tú decides cuánto guarda cada sala con `msgLimit.count` en la política de salas: al pasarse, se borran los más antiguos (los borrados se quedan como marca para la moderación). Con la base de datos llena, una línea nueva espera en la cola con las demás escrituras de los usuarios que ya estaban dentro (ver publicar.txt) y el historial ya la muestra; si la base de datos (o tu base) no responde, la línea espera a que vuelva y se escribe sola, y el historial también la muestra. En los dos casos `messageSaved` lleva `pending: true`. Borrar mensajes, salas o usuarios devuelve su sitio a la base de datos. Con base de datos propia (`neblla_byodb`) el chat va a tu base, tabla `nb_room_messages` (`neblla_nb_room_messages`), sin el id de Neblla de nadie, y no cuenta en la base de datos de Neblla; al pasar a ella, el chat se copia y Neblla no se queda con nada. **Neblla no cifra el chat.** Se guarda en claro, en la base de datos de la app o en tu base, para que lo puedas leer, buscar y moderar. Si tu app lo necesita, cifra los mensajes en ella antes de `send` (y descífralos al recibirlos y al leer el historial): Neblla los guarda y reenvía tal cual. `neblla.room.deleteMessage(messageId, name?)` **(CLIENT)** — soft-delete. Autor, owner o moderador. Emite `room.on('messageDeleted')` a todos. ## Seguir una sala Un usuario **sigue** una sala persistente y, cuando alguien escribe en ella mientras él no está dentro, se le apunta como nuevo; si tiene la app abierta (en el lobby o en otra sala) le llega un aviso al momento, y cuando vuelve ve cuántos mensajes nuevos tiene en cada sala que sigue. Sirve igual para el canal de una clase, el hilo de un foro o una partida por turnos («te toca»): cuenta cualquier mensaje que se queda en el historial de la sala (`neblla.message.send` en una sala `public` o `private`). Lo que cambia `room.data` o el estado del servidor no cuenta: en una partida por turnos, manda también la jugada con `message.send`. ```javascript await neblla.rooms.follow(name); // sin nombre, la sala en la que estás neblla.rooms.on('followedMessage', ({ room, unread, from }) => { aviso(`${from?.publicInfo?.name || 'Alguien'} escribió en ${room} (${unread} nuevos)`); }); neblla.rooms.on('following', ({ rooms }) => pintarInsignias(rooms)); // Al volver: neblla.rooms.following ya trae [{ name, type, unread, lastMessageAt, lastReadAt, followedAt }] ``` - **Quién puede seguir:** un usuario con login que es miembro de la sala: en una `public`, cualquiera; en una `private`, los de su lista. Errores: `login_required`, `not_whitelisted`, `room_not_found`, `not_followable` (el lobby, o una sala `ephemeral`), `follow_limit_reached` (hasta 200 salas por usuario y app). Seguir dos veces no hace nada. - **Qué cuenta:** cada mensaje guardado de otra persona mientras el seguidor no está dentro de la sala. El que escribe y quien está dentro no suman. - **Leído:** entrar en la sala la deja a 0 (y las demás pestañas del usuario lo oyen); `neblla.rooms.markRead(name?)` la deja a 0 sin entrar (`not_following` si no la sigue). `lastReadAt` dice desde cuándo pintar «nuevos» en el historial. - **Al volver:** al hacer login (o al recargar la página con login), `neblla.rooms.following` llega solo con las salas que sigue, la más reciente primero, y salta `rooms.on('following')`. `neblla.rooms.followed()` lo vuelve a pedir. - **Se deja de seguir** con `neblla.rooms.unfollow(name?)`, al abandonar o ser expulsado de una sala privada, al dejar de ser amigos (la sala de los dos) y cuando la sala se borra. - **El aviso no sale de la app:** llega solo a quien la tiene abierta; quien no, ve el número al volver. Neblla no manda email ni notificación push por esto. - **Cuota:** cada `follow`, `unfollow`, `followed` y `markRead` gasta como un mensaje (módulo `rooms`), y cada aviso y lista que llega, como cualquier respuesta. El dev ve cuántos siguen cada sala en `neblla_list_rooms` (`followers`). ## Receta: mensajes privados entre amigos Desde el perfil de un amigo (`neblla.social.friends`), un botón «Escribir» abre **la sala de los dos**: `neblla.rooms.direct(amigo)` la crea la primera vez y devuelve la misma todas las demás, la pida quien la pida. Es una sala `private` que no se borra nunca por inactividad (`ttlInactivity: 0`) y que los dos siguen («Seguir una sala»): lo que uno escribe mientras el otro no está le llega como aviso o como nuevos al volver. ```javascript // En el perfil de un amigo: amigo = una entrada de neblla.social.friends botonEscribir.onclick = async () => { const { name } = await neblla.rooms.direct(amigo); // o amigo.id await neblla.rooms.join(name); pintarConversacion(await neblla.room.history()); }; neblla.message.on(({ data, fromMe }) => { if (data?.type === 'dm') pintarLinea(data.text, fromMe); }); botonEnviar.onclick = () => neblla.message.send({ type: 'dm', text: caja.value }); // La lista de conversaciones, con sus nuevos: las salas directas que sigues. neblla.rooms.on('following', ({ rooms }) => pintarConversaciones(rooms)); neblla.rooms.on('followedMessage', ({ room, from }) => aviso(`${from?.publicInfo?.name || 'Un amigo'} te ha escrito`)); ``` - **Solo entre amigos:** hace falta el scope `friends` y una amistad aceptada; a quien no es amigo, `not_friends`, así que no sirve para escribir a desconocidos. A uno mismo, `self`; un id que no es de persona, `invalid_friend`; sin login, `login_required`. - **Solo los dos:** la sala es `direct` (`rooms.get`/`rooms.list` lo dicen), no tiene owners y nadie más entra; `social.inviteToRoom`, `room.kick`, `room.promote`/`demote` y `rooms.setType` responden `direct_room`. Su nombre (`DM_…`) es aleatorio. Los moderadores de la app siguen pudiendo moderarla. - **Salir:** `room.abandon(name)` saca a quien la deja de la lista y deja de seguirla. Si el otro vuelve a abrirla con `rooms.direct` y siguen siendo amigos, vuelve a la lista y a seguirla; para que no pueda, hay que dejar también la amistad (`social.remove`). Si los dos la dejan, se borra. - **Al dejar de ser amigos** (cualquiera de los dos, desde la app o desde su cuenta de Neblla), los dos salen de la sala a la vez: dejan de seguirla, quien estaba dentro vuelve al lobby (`room.on('userAbandoned')` con `reason: 'unfriended'`) y nadie puede entrar ni escribir en ella. La sala y su historial no se borran: si vuelven a ser amigos, `rooms.direct` los vuelve a meter a los dos y a seguirla. - **Es una sala privada más:** la política de salas de la app tiene que permitir las privadas (`type_not_allowed_by_policy` si no) y al crearla cuentan los topes por usuario (`per_user_create_limit_reached`, `per_user_participation_limit_reached`, `friend_participation_limit_reached`). Cuota: cada `rooms.direct` gasta como un mensaje (módulo `rooms`). ## Eventos de salas Ciclo de vida general: - `room.on('joined')` **(CLIENT)** — entraste a una sala. `{ room, isLobby }` - `room.on('left')` **(CLIENT)** — `{ room, user }` - `room.on('userJoined')` **(BOTH)** — `{ user, room, isLobby, alreadyPresent }`. - `room.on('userLeft')` **(BOTH)** — `{ user, room }` - `rooms.on('gathering')` **(CLIENT, lobby)** — refresh del listado de salas en formación. `{ rooms: [{ name, config, users }] }`. Reflejado en `neblla.rooms.gathering`. - `room.on('started')` **(BOTH)** — la sala pasó a `running`, la haya arrancado el presentador con `room.start()` o `server/gathering.js`. `{ room, isMe }` - `room.on('data')` **(BOTH)** — `{ room, data }`. - `room.on('dataError')` **(CLIENT)** — tu `room.setData(...)` fue rechazado (size cap u otro). - `room.on('closed')` **(CLIENT)** — la app pasó su cuota del día y el margen del 10 %, y el servidor cerró la sala y te sacó. `{ room, reason: 'quota_exceeded', resetsAt }` (`room: null` si la pestaña no estaba cuando se cerró). Lo que guardes aquí espera en el navegador hasta que vuelva la cuota; al resetearse vuelves al lobby (`room.on('joined')`). - `room.on('leftOut')` **(CLIENT)** — este dispositivo no llega a nadie de la sala: ni P2P directo, ni por el relé TURN si la app lo tiene, y el reenvío por el servidor está apagado (`connections: 'p2p'`, con el que nace una app nueva). No le llegan los mensajes de los demás. Neblla ya se lo ha dicho al usuario con un aviso; tu app puede añadir algo propio. `{ room, turn }`. Una vez por sala. - `rooms.on('joinError')` **(CLIENT)** — fallo entrando a una sala (`code: 'room_full'` si ya tiene `matchmaking.maxPlayers` usuarios, `code: 'quota_exceeded'` si la app gastó su cuota del día, `code: 'invalid_room_name'` si el nombre no es texto de 1-64 caracteres, `code: 'not_invited'` si la sala es por invitación y no estás en la lista, whitelist, kick, etc). Salas persistentes: - `room.on('userInvited')` **(CLIENT)** — owner añadió a invitados pendientes. - `rooms.on('invite')` **(CLIENT)** — al invitado: te invitan a una sala. `type: 'guests'` cuando es la lista de invitados de `rooms.book`. - `room.on('inviteAccepted')` / `room.on('inviteRejected')` **(CLIENT)** - `room.on('userKicked')` **(CLIENT)** — quitado de la whitelist por owner/moderador. - `rooms.on('kicked')` **(CLIENT)** — push directo al expulsado. - `room.on('userAbandoned')` **(CLIENT)** — se auto-quitó. - `room.on('ownerAdded')` / `room.on('ownerRemoved')` **(CLIENT)** - `rooms.on('deleted')` **(CLIENT)** — `{ room, reason, by? }`. - `rooms.on('typeChanged')` **(CLIENT)** Salas que sigues (ver «Seguir una sala»): - `rooms.on('followedMessage')` **(CLIENT)** — alguien escribió en una sala que sigues mientras no estabas dentro. `{ room, unread, from: { id, publicInfo }, time }`. - `rooms.on('following')` **(CLIENT)** — cambió la lista de salas que sigues (al hacer login, al seguir o dejar de seguir, con un mensaje nuevo, al leer). `{ rooms }`, reflejado en `neblla.rooms.following`. Chat persistido: - `room.on('messageSaved')` **(CLIENT)** — eco con el id necesario para borrar. `{ room, id, from, time, pending? }`: `pending: true` si la línea espera para escribirse (base de datos llena o sin respuesta); el historial ya la muestra y se escribe sola. - `room.on('messageDeleted')` **(CLIENT)** — render tombstone. - `room.on('messageDropped')` **(CLIENT)** — solo al autor, mensaje no persistido (too_large o rate_limited). ## Seguir conectados: la hora y el «¿sigues ahí?» Mientras la app está abierta, el SDK se mantiene en contacto con Neblla de dos maneras, y cada app elige cuánto (`neblla_update_app({ connection })`, "Pasar a pro" lo lleva a los usuarios): - **La hora común** (`neblla.room.now()`): al entrar se mide bien, con varias muestras, y después cada vez menos: a 1 s, 1,4 s, 2 s, 2,8 s… (×√2) hasta un techo, 1 hora por defecto. Un ajedrez de partidas de 3 minutos lo baja: `connection: { clock: { max: 120 } }`. Un chat puede fiarse de la hora del dispositivo: `clock: false` la mide solo al entrar. - **El «¿sigues ahí?»**: el SDK manda un latido cada `every` segundos, y si Neblla pasa `timeout` sin oírlo, corta su conexión (si era el servidor de la sala, otro toma el relevo en ese momento) pero le guarda el sitio 3 minutos (12 s si era el servidor), por si vuelve: un túnel o un cambio de wifi no lo saca de la partida. Si vuelve antes, sigue como si nada; si no, sale de la sala y los demás reciben su salida. Por defecto, una app con salas (el módulo `room`) lo nota en 45 s (`{ every: 15, timeout: 45 }`), para que en un juego se vea pronto quién se ha ido; cualquier otra, en 10 minutos (`{ every: 120, timeout: 600 }`), porque en un chat un fantasma de más no importa. `connection: { presence: { every: 60 } }` (el `timeout` es 4 × `every` si no lo das, y nunca menos de 2 ×). Con la página oculta (pantalla bloqueada, otra pestaña) el navegador frena sus temporizadores, así que Neblla le da siempre al menos 3 minutos. El latido va del dispositivo a Neblla y no tiene respuesta: ajustarlo no gasta ancho de banda de salida. En la cuota, el latido y la hora cuentan como mucho una vez cada 30 s cada uno, vayan como vayan. `null` vuelve a los valores por defecto.