Capítulo referencia de la guía Neblla — todos los métodos, eventos y propiedades del SDK, módulo a módulo, y la plantilla completa. El contrato base está en core.txt; el criterio de cuándo usar Neblla, en empezar.txt. # API de referencia Nada cuelga de la raíz de `neblla`: todo va en un módulo (`neblla.message`, `neblla.room`, `neblla.shop`…), y aquí cada sección es un módulo, en el mismo orden que la referencia del panel. Cada método indica dónde se puede llamar: (CLIENT), (SERVER) o (BOTH). En los ficheros de `server/`, `neblla` trae los mismos módulos con lo que tiene un servidor: sin `neblla.auth`, sin `neblla.room.me`, sin DOM. **Eventos: cada módulo escucha lo suyo.** `neblla..on(nombre, callback)` / `neblla..off(nombre, callback)` **(BOTH)**: `neblla.room.on('joined', cb)`, `neblla.shop.on('purchase', cb)`. `.off(nombre)` sin callback quita todos los de ese nombre; `.off()`, todos los del módulo. Un nombre que el módulo no tiene lanza `unknown_event`. Mensajes y estado no llevan nombre: `neblla.message.on(cb)` y `neblla.state.on(cb)`. En la raíz de `neblla` no hay `.on` ni `.off`. ## neblla.message — mensajes `neblla.message.send(data)` **(BOTH)** — a TODOS en la sala. Desde CLIENT, también te llega a ti mismo con `fromMe: true` (broadcast entre pares). Desde SERVER llega a todos los clientes con `from: 'server'` — es la voz autoritaria de la sala, no hay un "yo" al que reflejarlo. `neblla.message.to(target, data)` **(BOTH)** — a una parte de la sala. `target` es el id de un miembro, una lista de ids, un papel (`'presenter'`, `'master'`, `'board'`) o un grupo (sus miembros que están en la sala); desde CLIENT, también `'server'`; desde SERVER, también el alias de un jugador en una partida anónima. Un id que no está en la sala se toma como nombre de grupo. - **A un solo miembro:** `neblla.message.to(userId, data)`, desde CLIENT o SERVER, llega solo a ese miembro de la sala, con `private: true`; no se difunde ni queda en el historial de chat. Va por P2P si el canal está abierto y, si no, Neblla lo reenvía solo a esa persona. - **Al presentador:** `neblla.message.to('presenter', data)` **(CLIENT)** — solo llega al dispositivo con `isPresenter === true`, con `to: 'presenter'`. No se difunde al resto. - **Al servidor:** `neblla.message.to('server', data)` **(CLIENT)** — mensaje privado al server (el dispositivo que ejecuta el archivo de `server/` de la fase actual). Llega con `to: 'server'` a `server/room.js` si la sala está en `running`, a `server/gathering.js` si está en `gathering`, a `server/lobby.js` si estás en el lobby. No se difunde al resto de clientes. `neblla.message.on(callback)` / `neblla.message.off(callback)` **(BOTH)** — todos los mensajes, un solo evento: `{ from, to, data, user, time }`. `from` es el id de quien lo manda, o `'server'`; `to` es `null` (a toda la sala), un id, una lista, `'server'`, `'presenter'`, un papel o un grupo, tal como se mandó. Trae además `fromMe` y `p2p`. En el cliente llegan los de la sala, los de `server/` (`from === 'server'`) y, en el dispositivo del presentador, los que van a `'presenter'`. En `server/` llegan los de `message.to('server', …)` (`to === 'server'`, solo a `room.js`, o a `lobby.js` / `gathering.js` según el estado de la sala) y los que un cliente manda a toda la sala. ## neblla.state — el estado de la sala `neblla.state.set(state)` **(SERVER)** — emite el nuevo estado autoritario. Todos los dispositivos de la sala lo reciben en `neblla.state.on` y `neblla.state.get()` queda actualizado en cada uno. Va P2P entre los dispositivos y así no gasta cuota; si Neblla tiene que reenviarlo a alguno sin P2P, sus bytes cuentan una vez para la cuota del día. Es público: lo secreto va en un sobre de No-code (`neblla.tools.envelope`, ver servidor.txt). `neblla.state.merge(obj, { mode }?)` **(SERVER)** — funde `obj` con el estado actual y lo emite como `set`, con los modos de `neblla.db.save`: `'over'` (por defecto), `'under'`, `'delete'`. `neblla.state.get()` **(BOTH)** — el último estado emitido por el server. Útil en peers de respaldo al promocionarse — `room.js` arranca con el último valor ya disponible. `neblla.state.on(callback)` / `neblla.state.off(callback)` **(BOTH)** — el server emitió un nuevo estado con `state.set()`; el payload ES el estado. Todos los dispositivos lo reciben (incluidos los backups que tienen `room.js` cargado, listos para promocionarse). Se escucha al momento (`neblla.state.on(cb)`, puede rebobinarse) o solo lo definitivo (`neblla.state.on(cb, { final: true })`). ## neblla.room — la sala en la que estás ### Ciclo de vida Las salas nuevas empiezan en estado `gathering` (se entra con `neblla.rooms.join`, ver `neblla.rooms`) — `neblla.room.start()` las pasa a `running` y arranca `room.js`. `neblla.room.leave()` **(CLIENT)** — sale de la sala actual y vuelve al lobby. Dispara `room.on('left')`. `neblla.room.start(name?)` **(CLIENT o `server/gathering.js`)** — desde el cliente solo puede llamarlo el presentador; además puede llamarlo `server/gathering.js` para arrancar sola la sala. Pasa la sala de `gathering` a `running`. El SDK elige al server peer y arranca `room.js`. Con argumento `name`, hace `rooms.join` + `room.start` en una sola operación. `neblla.room.persist({ name, type, whitelist?, ttlInactivity? })` **(CLIENT)** — sube una sala recién reservada de `ephemeral` a `public` o `private`. `ttlInactivity` (días enteros 0–3650, `0` = nunca): cuánto aguanta esta sala sin actividad antes de borrarse, en lugar del plazo de su tipo (salas.txt); fuera de rango, `invalid_ttl`. Una sala `public` no puede pasar del plazo de la app para las públicas (ni ser `0`, salvo que ese plazo sea `0`): si no, `invalid_ttl` con `min: 1` y `max` = el plazo de la app. Llamar 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 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 `neblla.social.inviteToRoom`. `neblla.room.setData(data, { now? })` **(CLIENT)** — cualquier miembro, o solo sus moderadores si la sala los tiene (si no, `room.on('dataError')` con `not_moderator`). Fusiona `data` con `neblla.room.data`, clave a clave en el primer nivel (las claves que no mandas se quedan; para vaciar una, mándala con `null`) (persistente en salas `public`/`private`: Neblla la tiene en memoria y la escribe en los momentos clave; `{ now: true }` la escribe ya, una vez cada 10 s por sala como mucho). Todos los miembros de la sala 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')`. `neblla.room.now()` **(BOTH)** — la hora común de la sala, igual en todos los dispositivos. En `server/`, úsala en vez de `Date.now()`. ### Salas privadas, membresía `neblla.room.members(name?)` **(CLIENT)** — solo `private`. Devuelve `{ members[], invites[] }`. `neblla.room.kick(targetUserId)` **(CLIENT)** — expulsa de la sala privada actual. Solo owner o moderador. `neblla.room.abandon(roomName?)` **(CLIENT)** — renuncia a la membresía de una sala privada. Distinto de `room.leave`: leave = "me desconecto ahora", abandon = "quítame de la whitelist". `neblla.room.promote(targetUserId)` / `neblla.room.demote(targetUserId)` **(CLIENT)** — gestión de owners. Solo owner. No puedes auto-demotearte si eres el último. Las invitaciones se responden con `neblla.rooms.acceptInvite` / `rejectInvite`. ### Roles de sala (master y board) Dos roles opcionales **de cara al usuario**, no de servidor: nada de esto cambia quién ejecuta los ficheros de `server/`, y tomarlos no arranca la sala. Requieren la función `multiplayer` y no existen en el lobby (`not_in_room`). Toman un **userId por app**, así que un usuario anónimo también puede tenerlos. `neblla.room.transferMaster(targetUserId)` **(CLIENT)** — pasa el rol de *master* a otro miembro de la sala. Lo puede llamar el master actual; si todavía no hay master, también el presentador (fallback para que una sala no se quede sin). En cuanto hay master, el presentador deja de poder: si no, el rol sería recuperable a voluntad. En una sala con contraseña de master, el presentador nunca puede (`master_protected`). Errores: `not_authorized`, `master_protected`, `target_not_found`, `not_in_room`. `neblla.room.claimMaster(password)` **(CLIENT)** — toma el rol de *master* con la contraseña con la que se reservó la sala (`neblla.rooms.book({ masterPassword })`, 1–128 caracteres). Errores: `wrong_password`, `too_many_attempts` (con `retryInMs`), `no_master_password`, `not_in_room`. `neblla.room.setBoard(targetUserId | null)` **(CLIENT)** — marca a un miembro como *board*: el dispositivo de exhibición (la pantalla compartida, el tablero de la mesa). Misma autoridad que `transferMaster`. Es **exclusivo**: dárselo a uno se lo quita al anterior. Con `null` se limpia. Eventos: `room.on('master')` / `room.on('board')` **(CLIENT)** — `{ user, isMe, room }`, con `user: null` cuando el rol queda vacante. Propiedades: `neblla.room.master` y `neblla.room.board` (el user, o `null`); y en cada user, `isMaster` / `isBoard`. `neblla.room.me.isMaster` es el atajo habitual para ramificar la UI. ```javascript // El presentador nombra la pantalla grande y cede la dirección neblla.room.setBoard(tabletUserId); neblla.room.transferMaster(otroJugadorId); // Repinta en cada cambio de rol y ramifica por los roles ACTUALES, nunca por `isMe`: // en una vacante (user: null) ningún dispositivo tiene isMe true, así que un isMe // pelado deja a nadie pintando la mesa. Ver la regla de rama en empezar.txt. let enLobby = true; // ningún evento lo lleva salvo room.on('joined'), así que llévalo tú const esMesa = () => !enLobby && (neblla.room.me.isBoard || (!neblla.room.board && neblla.room.me.isPresenter)); const pintar = () => esMesa() ? renderVistaDeMesa() : renderVistaDeMano(); neblla.room.on('joined', (e) => { enLobby = e.isLobby; pintar(); }); // el valor cambia AQUI tambien neblla.room.on('board', () => esMesa() ? renderVistaDeMesa() : renderVistaDeMano()); neblla.room.on('presenter', () => esMesa() ? renderVistaDeMesa() : renderVistaDeMano()); ``` ### Historial de chat (persistentes) `neblla.room.history({ name?, before?, limit? })` **(CLIENT)** — paginación de chat persistido. Newest-first. Default limit 50, max 200. Los mensajes borrados vienen con `body: null` y `deleted: true`. `neblla.room.deleteMessage(messageId, name?)` **(CLIENT)** — soft-delete. Autor, owner o moderador. Emite `room.on('messageDeleted')` a todos. ### Ficheros P2P `neblla.room.sendFile(file, opts?)` **(CLIENT)** — transferencia P2P (WebRTC) de un fichero a los demás peers de la sala **con los que ya hay canal P2P abierto** — no abre uno nuevo. Sin `to`, a los que no lo tienen se los salta (resuelve con `{ fileId, sent }`, y `sent` dice a quién llegó; si no hay ninguno, rechaza con `no_p2p_peers`). Con `to`, si alguno de los nombrados no tiene canal, rechaza entero con `peer_not_p2p` y la lista en `peers`. Nunca cae al relay del servidor. **Límites**: 100 MB por fichero (más grande rechaza con `file_too_large` y el máximo en `max`) y hasta 4 transferencias a la vez de un mismo emisor hacia cada receptor — varios ficheros, mejor de uno en uno. Emite `room.on('fileStart')` / `('fileProgress')` / `('file')` para que los receptores muestren progreso y lean el blob final. No pasa por Neblla — directo entre dispositivos. El emisor manda el SHA-256 del fichero; el receptor lo verifica sobre el blob reensamblado antes de emitir `'file'`. Si no cuadra (transferencia truncada/corrupta) emite `'fileError'` en vez de `'file'` — nunca entrega un blob roto en silencio. ### Propiedades `neblla.room.name` **(BOTH)** El nombre de la sala actual. `neblla.room.type` **(BOTH)** `ephemeral`, `public` o `private`. `neblla.room.phase` **(BOTH)** `gathering` o `running`. `neblla.room.users` **(BOTH)** Array con los users de la sala. `neblla.room.config` **(BOTH)** El JSON libre que pasaste a `rooms.book` (así llegan esos ajustes a `gathering.js` / `room.js`). `neblla.room.data` **(BOTH)** El `room.data` persistente de una sala public/private. `neblla.room.me` **(CLIENT)** Tú en la sala: el mismo objeto que sale en `neblla.room.users`. Tú en la app (también como invitado) es `neblla.auth.me`. `neblla.room.presenter` **(BOTH)** El user presentador actual. `neblla.room.master` / `neblla.room.board` **(BOTH)** El user con ese rol, o `null`. `neblla.room.masterProtected` **(CLIENT)** `true` si la sala se reservó con `masterPassword`; la contraseña nunca viaja. Tu alias en una partida anónima (`neblla.tools.anonymize()`, ver servidor.txt) te llega solo a ti en `neblla.room.on('alias', ({ alias }) => …)` y queda en `neblla.room.me.alias`. No existe `user.isServer` — el servidor es opaco a propósito. El objeto user expone `{ id, publicInfo, isPresenter, isMaster, isBoard, isMe, purchases }`. `publicInfo` es **siempre un objeto** — `{}` hasta que el usuario llama a `neblla.db.setPublicInfo()`, nunca `undefined`, así que `u.publicInfo.name` es seguro. ### Eventos: `neblla.room.on(nombre, cb)` `'joined'` **(CLIENT)** — entraste a una sala (también al recargar, con `isReconnect: true`). `{ room, isLobby }` `'started'` **(BOTH)** — la sala pasó a `running`. NO significa que el presentador haya pulsado nada: pudo llamarlo el presentador o `server/gathering.js`, que es el camino normal con `matchmaking.mode: "nocode"`. No uses este evento para atribuir el arranque a una persona. `room.js` arranca en el server peer. `isMe` true si tú eres el peer. `{ room, isMe }` `'server'` **(BOTH)** — cambió el Fog que manda (el anterior se fue). Uno de los que le seguían ya tenía el estado y los timers, y sigue sin cortes. `{ room, isMe }` `'flagged'` **(CLIENT)** — sala marcada como sospechosa. Una vez por sala (anti-troll). `{ room, reason, flaggerId, userCount }` `'left'` **(CLIENT)** — `{ room, user }` `'userJoined'` **(BOTH)** — `{ user, room, isLobby, alreadyPresent }`. `alreadyPresent` es true para los que ya estaban cuando tú entraste (snapshot inicial). `'userLeft'` **(BOTH)** — `{ user, room }` `'presenter'` **(CLIENT)** — presentador cambió. `{ user, isMe, room, isLobby }`. `'publicInfo'` **(CLIENT)** — `{ user, publicInfo }`. `'data'` **(BOTH)** — alguien llamó `room.setData(...)`. `{ room, data }`. `'dataError'` **(CLIENT)** — tu `room.setData(...)` fue rechazado (size cap u otro). `'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')`). `'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. `'fileStart'` / `'fileProgress'` / `'file'` / `'fileError'` **(CLIENT)** — transferencia P2P de `room.sendFile`. `'file'` trae el blob completo, ya verificado por SHA-256. `'fileError'` (`{ reason: 'checksum_mismatch' | 'size_mismatch', ... }`) salta cuando el blob reensamblado no cuadra con lo declarado por el emisor. **`fileProgress` es asimétrico**: al receptor le llega `{ received, total, fromMe: false }` y al emisor `{ sent, total, fromMe: true }`. Es el mismo concepto con campos distintos, así que una barra de progreso tiene que leer los dos — `const done = event.received ?? event.sent;` — o se quedará a cero en una de las dos puntas. Salas persistentes **(CLIENT)**: `'userInvited'` — owner añadió a invitados pendientes. `'inviteAccepted'` / `'inviteRejected'`. `'userKicked'` — quitado de la whitelist por owner/moderador. `'userAbandoned'` — se auto-quitó. `'ownerAdded'` / `'ownerRemoved'`. Chat persistido **(CLIENT)**: `'messageSaved'` — eco con el id necesario para borrar, `{ room, id, from, time, pending? }` (`pending: true`: la línea espera para escribirse, base de datos llena o sin respuesta; se escribe sola). `'messageDeleted'` — render tombstone. `'messageDropped'` — solo al autor, mensaje no persistido (too_large o rate_limited). Partidas anónimas y avisos: `'alias'` **(CLIENT)** — `{ alias, room }`, tu alias para la partida. `'announce'` — lo que un jugador dice en público con `neblla.tools.announce` (ver servidor.txt). `'hook'` — un webhook entrante o una Automation entregó datos a la sala (ver core.txt). ### Los ficheros de `server/` `neblla.server.on('start' | 'stop', callback)` **(SERVER)** — `{ file }`. `'start'` salta cuando el fichero (`lobby.js`, `gathering.js` o `room.js`) ya ejecutó su código de arriba; `'stop'`, antes de pararlo, para limpiar timers o mandar lo último. En el cliente no existe `neblla.server`. ## neblla.rooms — todas las salas `neblla.rooms.book({ config?, length? })` **(BOTH)** — reserva un nombre de sala único. Devuelve `Promise`. `length` (3–12, por defecto 5) fija cuántos caracteres tiene el código, p. ej. `{ length: 3 }` si tu UI tiene 3 casillas. **No entra en la sala** — llama a `neblla.rooms.join(name)` después. El `config` opcional se persiste en la sala futura y se expone como `neblla.room.config` dentro de `gathering.js` y `room.js`. `neblla.rooms.join(roomName)` **(CLIENT)** — entra en una sala. Dispara `room.on('joined')`. Las salas nuevas empiezan en estado `gathering` — el presentador llama a `neblla.room.start()` para pasar a `running` y arrancar `room.js`. `neblla.rooms.list({ type? })` **(CLIENT)** — lista salas persistentes accesibles al usuario. Cada entrada: `{ name, type, creator, memberCount, activeUsers, lastActivityAt }`. `neblla.rooms.get(name?)` **(CLIENT)** — inspecciona una sala por nombre (por defecto, la actual). `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. `neblla.rooms.acceptInvite(roomName)` / `neblla.rooms.rejectInvite(roomName)` **(CLIENT)** — responde a una invitación (`room.on('userInvited')`). `neblla.rooms.gathering` **(CLIENT, lobby)** — array de salas en formación visibles desde el lobby, cada una con `{ name, config, users }`. Se mantiene sincronizado con `rooms.on('gathering')`. `neblla.rooms.follow(name?)` / `neblla.rooms.unfollow(name?)` **(CLIENT)** — seguir o dejar de seguir una sala persistente de la que eres miembro (por defecto, la actual); `follow` devuelve su entrada. Errores: `login_required`, `not_whitelisted`, `room_not_found`, `not_followable`, `follow_limit_reached` (200). Detalle en salas.txt, «Seguir una sala». `neblla.rooms.followed()` **(CLIENT)** — pide al servidor las salas que sigues y actualiza `neblla.rooms.following`. `neblla.rooms.markRead(name?)` — deja a 0 sus nuevos sin entrar (`not_following`). `neblla.rooms.direct(friend)` **(CLIENT)** — la sala privada de los dos con un amigo (`friend` o `friend.id`, de `neblla.social.friends`): la crea la primera vez, después devuelve la misma; no se borra por inactividad y los dos la siguen. Devuelve `{ name, created }`; entra con `rooms.join(name)`. Errores: `login_required`, `scope_missing`, `not_friends`, `self`, `invalid_friend`, `type_not_allowed_by_policy`, los topes de salas privadas. Receta en salas.txt. `neblla.rooms.following` **(CLIENT)** — las salas que sigues, `[{ name, type, unread, lastMessageAt, lastReadAt, followedAt }]`, la más reciente primero. Llega solo al hacer login y se mantiene con `rooms.on('following')`; `[]` sin login. ### Eventos: `neblla.rooms.on(nombre, cb)` `'gathering'` **(CLIENT, lobby)** — refresh del listado de salas en formación. `{ rooms: [{ name, config, users }] }`. Reflejado en `neblla.rooms.gathering`. `'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, whitelist, kick, etc). `'invite'` **(CLIENT)** — `{ from, roomName, appId, type }`. `'kicked'` **(CLIENT)** — push directo al expulsado. `'deleted'` **(CLIENT)** — `{ room, reason, by? }`. `'typeChanged'` **(CLIENT)**. `'followedMessage'` **(CLIENT)** — alguien escribió en una sala que sigues mientras no estabas dentro. `{ room, unread, from: { id, publicInfo }, time }`. `'following'` **(CLIENT)** — cambió `neblla.rooms.following`. `{ rooms }`. ## neblla.auth — quién es el usuario `neblla.auth.login(opts?)` **(CLIENT)** — SSO. `opts.scopes` lista los permisos pedidos: `email`, `friends`, `discoverable`. Llámalo dentro del clic del usuario: la ventana de login se abre en ese instante (así el navegador no la bloquea) y, si el usuario ya tenía login, se cierra sola. Con `{ token }`, hace login con tu propio login: el token de un solo uso que tu servidor pidió a Neblla (ver identidad.txt). `neblla.auth.logout()` **(CLIENT)** — hace logout de esta app. No hace logout del SSO de Neblla. `neblla.auth.idToken()` **(CLIENT)** → `{ token, expiresIn }`, JWT firmado por Neblla para que tu servidor sepa quién es el usuario (ver identidad.txt). `neblla.auth.requestErasure()` **(CLIENT)** — abre la ventana de Neblla donde el usuario pide que borres lo que dio a tu app (ver identidad.txt). `neblla.auth.me` **(CLIENT)** — tú en la app, también como invitado: `{ id, publicInfo, grantedScopes, loggedIn }`. Tu papel en la sala está en `neblla.room.me`. ### Eventos: `neblla.auth.on(nombre, cb)` `'login'` **(CLIENT)** — `{ type, data, grantedScopes, appUserId, email? }` (`appUserId`: el id del usuario en tu app, el mismo en todos sus dispositivos). `'logout'` **(CLIENT)** — `{}`. `'expired'` **(CLIENT)** — la sesión caducó. `{}`. `'error'` **(CLIENT)** — `{ code: 'popup_blocked', url }`: el navegador bloqueó la ventana de login. Pinta un botón que abra `url` (un clic es un gesto nuevo y el navegador lo deja). `{ code: 'store_unavailable' }`: la base de datos propia de la app (BYODB) no responde y el login no llega a hacerse, sea cual sea el camino de entrada; llega también por `neblla.app.on('error')` (ver abajo), y `neblla.auth.login({ token })` / `({ jwt })` lo rechazan con ese código. El usuario sigue como invitado. `'switch'` / `'tokenExpired'` **(CLIENT)** — cambio de cuenta y token de tu propio login caducado (ver identidad.txt). ## neblla.db — lo que guarda el usuario `neblla.db.save(json, opts?)` **(CLIENT)** — persiste datos. Local o servidor según login. Por defecto solo toca lo que mandas (`mode: 'over'`) y se cobra solo eso. `opts.mode` = `'over'` | `'under'` | `'full'` | `'delete'`; `opts.empty` = qué cuenta como vacío (`'null'`, `'emptyArray'`, `'emptyString'`); `opts.arrays` = `'replace'` | `'push'` | `'pull'` | `'complete'` (o por campo, con `unique` y `sort`); `opts.crdt` = gana el más nuevo por campo entre dispositivos; `opts.at` = la hora del cambio (`Date` o ms), para mandar tarde lo que se hizo sin conexión: activa `crdt`, solo con `'over'`; `opts.table` = una fila en una tabla tuya (ver identidad.txt); `opts.now` = mandarlo ya. Con login, el guardado espera en el navegador y sale en los momentos clave (cerrar o salir de la página, cambiar de pestaña, salir de una sala, entrar desde otro sitio, `now`, cada 10 minutos como mucho); los seguidos en `over` se juntan. Devuelve una promesa: al momento `{ data, mode, pending, onDevice: true }`; con `now`, cuando el servidor lo guardó (`onDevice: false`), o falla con `code` (`save_too_large`, `invalid_save`, `invalid_at`, `login_required`, `quota_exceeded`, `save_loop`; con `onDevice: true` si sigue esperando). Cuando un lote llega, `neblla.db.data` se actualiza y se dispara `db.on('merged')`. `neblla.db.data` **(CLIENT)** — tus saves. `neblla.db.waiting` **(CLIENT)** — cuántos guardados de este usuario esperan en este dispositivo a salir (ver identidad.txt). `neblla.db.pending` **(CLIENT)** — `true` mientras tus guardados esperan en la cola porque la base de datos de la app está llena (ver publicar.txt); tus datos ya los llevan encima y se escriben cuando haya sitio. `false` en cuanto uno llega a la base de datos. Con la base de datos llena, una fila guardada con `{ table }` vuelve con `pending: true` y su `id`, y `db.delete()` con `{ removed: true, pending: true }`. `neblla.db.setPublicInfo(info)` **(CLIENT)** — tu perfil público (visible a toda la sala). Nombres, avatares, colores. `neblla.db.load({ table, group?, where?, orderBy?, limit? })` / `neblla.db.delete({ table, group?, id })` **(CLIENT, login)** — tus filas, o las de tu grupo según tu papel; se escriben con `neblla.db.save(data, { table, group?, id? })` (ver identidad.txt, "Tablas"). `neblla.db.watch(…)`, `shareLink`, `openLink`, `links`, `revokeLink`, `pendingLink` **(CLIENT)**: avisos en directo y enlaces de solo lectura (`openLink` sin login). En `server/`, `neblla.db.load`, `save` y `delete` con `{ table }` **(SERVER)**; en `save`, `{ user }` dice de qué usuario de la sala es la fila. `neblla.db.dataOf(user)` / `neblla.db.loadOf(user, { table, where?, orderBy?, limit?, offset? })` **(CLIENT)** — lo de otro usuario que tu app deja ver (`neblla_update_app({ visible })`): la parte de su `db.data` marcada para quien lee (un objeto, `{}` si no le toca nada) y sus filas de una tabla marcada, sin dueño. `user`: su id en la app, su id en la sala, el `id` de un amigo de `neblla.social.friends`, o el objeto (`author` de la comunidad, un usuario de la sala, un amigo). Errores: `not_visible` (la app no marca nada, o esa tabla), `not_friends` (tabla solo para amigos), `user_not_found`, `unknown_table`. Ver identidad.txt, «Lo que ven otros usuarios». `neblla.db.offer({ to, give, get, expires? })` / `neblla.db.offers({ status?, limit? })` / `neblla.db.acceptOffer(id)` / `neblla.db.declineOffer(id)` / `neblla.db.cancelOffer(id)` **(CLIENT, login)** — intercambios entre usuarios (`neblla_update_app({ trade })`): ofrecer algo suyo (`give`) por algo del otro (`get`); cada cosa es `{ table, id }` (una fila suya) o `{ product, amount }` (unidades de lo comprado). `offer` devuelve la oferta (`{ id, from, to, give, get, status: 'pending', expiresAt, mine: 'sent' }`); `offers` las suyas, pendientes por defecto (`status: 'all'` o uno: `done`, `declined`, `cancelled`, `expired`, `failed`); `acceptOffer` lo mueve todo de una vez o nada, y devuelve la oferta `done`. `neblla.db.on('offer', ({ action, offer }) => …)`: `new`, `done`, `declined`, `cancelled`, `failed`, en los dispositivos de los dos. Ver identidad.txt, «Intercambios entre usuarios». ### Eventos: `neblla.db.on(nombre, cb)` `'merged'` **(CLIENT)** — el servidor resolvió un `db.save()`. `{ data, mode, pending }` — `data` es el doc resultante (ya copiado a `neblla.db.data`); `pending: true` si la base de datos está llena y el guardado espera en la cola (no es un error: el juego sigue con él). `'error'` **(CLIENT)** — un `db.save()` no se guardó. `{ reason }`, por ejemplo `invalid_save` si `data` no es un objeto, `invalid_at` si `at` no es una hora o el modo no es `'over'`, `save_too_large` (con `bytes` y `max`), `save_loop` (más de 1000 esperando en el dispositivo), o el error de una fila (`too_many_rows`…); los que el servidor no aceptará nunca traen `save` (`{ k: 'doc' }` o `{ k: 'row' | 'del', table, id }`) y se descartan. ## neblla.scores — puntuaciones de un juego para uno `neblla.scores.start(ranking)` **(CLIENT, login)** — empieza una partida en Neblla: `{ run, seed, startedAt, until }`. Cierra la que el usuario tuviera abierta en ese ranking. Rechaza con `runs_per_day` (lleva `max`), `unknown_board`, `login_required`. `neblla.scores.submit(partida, valor, { data? })` **(CLIENT, login)** — manda la puntuación de esa partida (lo que dio `start`, o su `run`), una vez. Neblla la comprueba con los límites del ranking y, si lo declaraste, con tu propia comprobación (`check`, que recibe `data`). `{ ok: true, value, best, newBest, place }` si entra; `{ ok: false, reason }` si no: `below_min`, `over_max`, `too_soon`, `too_fast`, `check_refused` (marcan al usuario), `invalid_value`, `run_expired`, `run_used`, `run_closed`, `run_not_found`, `check_unavailable` (con `retry: true`: se puede repetir). `neblla.scores.top(ranking, { period?, limit?, friends? })` **(CLIENT)** — los mejores: `period` `'all'` (por defecto) o `'day'` (hoy, UTC); `limit` hasta 100; `friends: true`, solo el usuario y sus amigos que dieron a la app el permiso de amigos, con el puesto entre ellos (pide login y el permiso de amigos: `login_required`, `scope_missing`). `[{ place, player, value, at, info?, me? }]` (`player`, el id del usuario en la app; `info`, su `publicInfo`; `me: true` en la fila propia). También para invitados. `neblla.scores.mine(ranking)` **(CLIENT, login)** — la marca propia: `{ best, bestAt, dayBest, runs, place }` o `null`. Los rankings se declaran con `neblla_update_app({ scores })` o en el panel, Funciones › Puntuaciones y clasificaciones (ver identidad.txt, "Puntuaciones de un juego para uno"). ## neblla.media — fotos, texturas y sonidos de la app Los medios de la app (fotos de una galería, texturas de un juego, sonidos, vídeos, modelos `glb`), muchos y aparte de sus ficheros: los sube el dev con `neblla_media` o en el panel (Construir › Medios), sin versiones ni `neblla_promote`, y la app los lee siempre al momento, en pre y en pro. Sin login. `neblla.media.collection(nombre)` **(CLIENT)** — una colección, por su nombre («Bodas») o su clave («bodas»), en su orden: `{ collection, key, changedAt, items: [{ id, kind, mime, title, text, alt, fields, width?, height?, color?, url, srcset?, variants?, original }] }`. `fields` son los campos propios del dev (`{ year: 2023, size: "70 x 45 cm", price: 520, available: true }`); `url`, la variante WebP de hasta 1280 px; `srcset`, todas (320, 640, 1280, 2048, nunca más anchas que el original); `original`, el fichero tal como se subió; `color`, el color dominante, para el fondo mientras carga. Se lee de Neblla cada vez (con ETag: si no cambió, casi no cuesta), así que un cambio del dev se ve en la siguiente lectura sin tocar el código. Rechaza con `collection_not_found`. `neblla.media.collections()` **(CLIENT)** — `[{ collection, key, count, changedAt, cover }]`, `cover` el primer elemento o `null`. `neblla.media.img(elemento, { sizes?, loading? })` **(CLIENT)** — un `` listo: `src`, `srcset` con `sizes` (`'100vw'` por defecto), ancho y alto (sin saltos al cargar), `alt` (o el `title`), `loading: 'lazy'` y el color dominante de fondo. ```js const { items } = await neblla.media.collection('Obra'); for (const it of items) grid.append(neblla.media.img(it, { sizes: '(min-width: 800px) 33vw, 100vw' })); ``` **Ficheros a la venta** (C230): un medio privado trae `private: true`, `bytes`, `unlockedBy: { products, credits }` y `original: null`; quien tiene derecho lo descarga con `neblla.media.download(id)` **(CLIENT, login)**, un enlace de 5 minutos directo del bucket (`{ url, expiresAt, name, mime, bytes, via }`; `{ open: false }` solo lo devuelve), gasta un bono en uno con `neblla.media.unlock(id, kind?)` y lee lo suyo con `neblla.media.mine()`, también el contenido privado de lo que compró (`content`: un enlace o un texto, C330). La historia entera, en pagos.txt, "Ficheros a la venta". ## neblla.app — avisos de la app `neblla.app.on('error', cb)` **(CLIENT)** — el canal de error general del SDK: el servidor ha bloqueado algo y, sin escucharlo, tus eventos se quedarían muertos en silencio. `{ code }`, donde `code` es `'quota_exceeded'` (la app gastó su presupuesto del día; se resetea a las 00:00 UTC; trae `resetsAt` y `stage`: `'grace'` mientras las salas en marcha siguen y solo se rechaza lo nuevo, `'closed'` cuando ya se cerraron), `'player_quota_exceeded'` (este usuario gastó su parte del día de la app, `playerQuota`; también hasta las 00:00 UTC), `'storage_full'` (la base de datos de la app está llena y hay escrituras en la cola: no entra nadie nuevo hasta que el dev haga sitio), `'store_unavailable'` (la base de datos propia de la app, BYODB, no contesta: entrar, guardar y comprar se paran hasta que vuelve; el usuario no puede hacer login por ningún camino y sigue como invitado, y la ventana de Neblla le dice por qué; quien ya está dentro sigue; también deja un aviso en consola), `'app_not_found'` (el dev borró la app; también deja un aviso en consola) o `'app_maintenance'` (la base de la app se está mudando; una llamada que espera respuesta la recibe también, como `{ error: 'app_maintenance' }`, y los guardados que esperan en el navegador se reintentan luego). El caso de cuota además deja un aviso en consola. Escúchalo si quieres ensenar una pantalla de "hoy se nos acabó" en vez de una app congelada. `neblla.app.on('maintenance', cb)` **(CLIENT)** — el dev está migrando la BBDD de la app a otro backend (ver identidad.txt). Tu socket se desconectará en ~2 segundos; el SDK vuelve a conectar solo pasados `retryInSeconds` (mínimo 5 s). Si vuelve antes de que acabe, oye otro `maintenance` y se le suelta otra vez; no hay aviso de fin: cuando vuelve a entrar, ya terminó. Aprovéchalo para mostrar un mensaje tipo "estamos en mantenimiento, vuelve en un momento". `{ reason: 'byodb_migration', retryInSeconds: 30 }`. `neblla.app.permission(name, opts?)` **(CLIENT)** — pide un permiso del navegador: `'microphone'`, `'camera'`, `'geolocation'` o `'midi'` (con `{ sysex: true }` pide también SysEx). Resuelve `'granted'` o `'denied'` (un nombre desconocido, `'denied'`). Tu app corre dentro de un iframe en la galería, así que el permiso lo pide la página de Neblla y el aviso del navegador dice «neblla»; si llamas directamente a `getUserMedia` o a la geolocalización, el SDK hace lo mismo por ti la primera vez. `neblla.app.devPreview` **(CLIENT)** — `true` solo en el pre de la app (su enlace privado, o tu página en `localhost`); nunca para los usuarios de pro. Úsalo para enseñar atajos de dev, como «configurar productos». `neblla.app.dashboardUrl({ tab?, section? } | { account })` **(CLIENT)** — enlace al panel de esta app (`{ tab: 'products' }`) o a una pestaña de tu cuenta (`{ account: 'claude' }`). Va bien junto a `devPreview`. `neblla.app.debugging` **(CLIENT)** — encendido por defecto: el SDK emite `neblla.app.on('debug', cb)` con `{ label, data }` en sus pasos internos. Ponlo a `false` para callarlo. ## neblla.social — amigos e invitaciones Requiere el scope `friends`, salvo `inviteToApp`. `neblla.social.request(target)` — petición a un usuario online en esta app, o `neblla.social.request({ code })` con su código de amistad aunque no esté conectado (espera en su buzón). `neblla.social.code()` → el código del usuario, o `null`. `neblla.social.accept(requestId)` / `neblla.social.reject(requestId)`. `neblla.social.remove(friendId)` — funciona offline. `neblla.social.inviteToRoom(target)` — añade a la whitelist de tu sala privada. `neblla.social.inviteFriend(target)` — invita casual a un amigo a tu sala (cualquier tipo). `neblla.social.inviteToApp()` — abre un popup de Neblla para invitar a un amigo de Neblla A TU APP (la app no ve la lista de amigos). Requiere login. Fire-and-forget: la invitación va a la bandeja del amigo en play.neblla.com (caduca en 5 min) y lo trae a tu app. No requiere el scope `friends`. `neblla.social.list()` — refresh manual de `friends` y `requests`; devuelve los amigos. `neblla.social.friends` **(CLIENT)** Amigos visibles en esta app. `neblla.social.requests` **(CLIENT)** Peticiones recibidas. `neblla.social.invitedBy` **(CLIENT)** Quién te invitó a esta app vía bandeja de Neblla, o `null`. `{ id, name, picture, slug, userId|null }`. ### Eventos: `neblla.social.on(nombre, cb)` `'updated'` **(CLIENT)** — `{ friends }`. `'request'` **(CLIENT)** — `{ requestId, from }`. `'accepted'` / `'removed'` **(CLIENT)**. `'appInvite'` **(CLIENT)** — un amigo de Neblla te invitó a esta app (llegaste por el enlace de la bandeja, o te invitó estando ya dentro). `{ from }` con `from = { id, name, picture, slug, userId|null }`. También rellena `neblla.social.invitedBy`. ## neblla.shop — compras `neblla.shop.buy(productId, amount?, opts?)` **(CLIENT)** — abre checkout. Un invitado compra con la cuenta ligera; con `emailCheckout: false`, haz login antes en la misma ventana. `opts.series`: el horario elegido de una plaza fija con varios (`series_required` con `choices` si falta). `neblla.shop.consume(productId, amount)` **(CLIENT)** — consume unidades. `neblla.shop.validate(userId, productId)` **(BOTH)** — verifica posesión. `neblla.shop.products()` **(BOTH)** — lista productos. `neblla.shop.manage(productId)` **(CLIENT, login)** — abre el portal de Stripe del suscriptor (ver pagos.txt). `neblla.shop.restore(email)` **(CLIENT)** — con la cuenta ligera (`emailCheckout`, encendida por defecto), manda el enlace que une este dispositivo a las compras de ese email. `neblla.shop.purchases` **(CLIENT)** — tus compras, por productId. Las de los demás no viajan: compruébalas con `shop.validate`. `neblla.shop.on('purchase', cb)` **(CLIENT)** — `{ userId, productId, type, purchases }`, solo en los dispositivos del comprador. ## neblla.inbox — comunicaciones con el dev `neblla.inbox.send({ message, data?, dateFrom?, dateTo?, thread? })` **(CLIENT)** — manda mensaje al backoffice. `neblla.inbox.update(commId, {...})` **(CLIENT)** — edita (mientras no esté `read`). `neblla.inbox.delete(id)` **(CLIENT)** — borra propia. `neblla.inbox.messages` **(CLIENT)** — array de comunicaciones del usuario. `neblla.inbox.on('messages', cb)` **(CLIENT)** — sync inicial. `neblla.inbox.on('updated', cb)` **(CLIENT)** — `{ communication, action }`. ## neblla.ai — IA `neblla.ai.prompt(text, { preset?, model?, provider?, userToken? }?)` **(CLIENT)** — devuelve `Promise<{ text, usage, quota }>`. `provider`: `'claude'` | `'openai'` | `'deepseek'`, el de la app por defecto; otro solo cuando paga el usuario (ver ia.txt). `neblla.ai.on('reply', cb)` **(CLIENT)** — respuesta de `neblla.ai.prompt()`. ## neblla.api — APIs de terceros y keys `neblla.api.call(name, path, body?, { method }?)` **(BOTH)** — llama a una API externa con un secreto guardado con `neblla_set_api_secret`; Neblla pone el secreto y solo llama bajo su URL base. Devuelve `{ status, contentType, body }`. Desde el cliente, solo si el secreto tiene `fromClient: true` (ver core.txt). `neblla.api.key(name)` **(BOTH)** — el valor actual de una key que el dev puso en el backoffice o con `neblla_update_app({ keys })`. Público: nunca secretos (ver core.txt). ## neblla.anticheat — anti-cheat `neblla.anticheat.confirm(eventName, value)` **(CLIENT)** — necesita el módulo `anticheat` (`features: { anticheat: true }`) — observar y reportar. `neblla.anticheat.flag(reason?)` **(SERVER)** — marcar sala como sospechosa. Los jugadores lo ven en `room.on('flagged')`. ## neblla.groups — grupos `neblla.groups.mine()` **(CLIENT, login)** — tus grupos, `[{ group, role }]`; `create`, `invite`, `join`, `leave`, `members` y `pendingInvite` si la app lo permite. En Server, `neblla.groups.create`, `delete`, `add`, `remove`, `list`. ## neblla.community — comunidad Todos devuelven promesa; rechazan con `err.code`. Detalle en comunidad.txt. `neblla.community.publish({ title, summary?, category?, tags?, lang?, contentType, payload, parentId?, visibility? })` **(CLIENT, login)** → `entry`. Nace `published` o `pending` según el modo de moderación. `neblla.community.update(entryId, patch)` **(CLIENT, login)** → `entry`. Solo el autor o un moderador. `neblla.community.unpublish(entryId)` **(CLIENT, login)** — el autor retira su entry (borrado real). `neblla.community.mine(opts?)` **(CLIENT, login)** → `{ entries, next }`, todos los estados propios. `neblla.community.list({ q?, category?, tags?, lang?, contentType?, owner?, parent?, sort?, cursor?, limit? })` **(BOTH)** → `{ entries, next }` solo `published`, sin payload. `neblla.community.facets(['category'|'tags'|'lang'|'contentType'], { parent? })` **(BOTH)** → conteos por valor. `neblla.community.get(entryId)` **(BOTH)** → `{ entry, payload }`. `neblla.community.relate(entryId, { favorite?, rating?, review?, data? })` **(CLIENT, login)** → `{ relation, stats }`. Merge parcial; `null` borra una ranura. `data` es tuyo: un objeto JSON libre de hasta 4 KB en la relación de ese usuario con esa entrada (un marcador, una nota, su mejor tiempo); vuelve en `relation.data` y no cuenta en las estadísticas. El autor no valora ni reseña lo suyo. `neblla.community.vote(entryId, 1 | -1 | null)` **(CLIENT, login)** → `{ vote, stats }`. Un voto por persona y entrada: `1` a favor (lo que hace `vote(entryId)` sin más), `-1` en contra solo si la app lo enciende (`community.downvotes: true`; si no, `downvotes_off`), `null` o `0` lo quita. Suma en `stats.upvotes`, `stats.downvotes` y `stats.score` (a favor menos en contra); `list({ sort: 'votes' })` ordena por él. El autor no vota lo suyo (`own_entry`). `neblla.community.relation(entryId)` **(CLIENT, login)** → `{ favorite, rating, review, reported, vote }`. `neblla.community.favorites(opts?)` **(CLIENT, login)** → `{ entries, next }`, mis favoritas publicadas. `neblla.community.reviews(entryId, opts?)` **(BOTH)** → `{ reviews, next }`, reseñas públicas. `neblla.community.report(entryId, { reason, message? })` **(CLIENT, login)** → `{ ok, suspended }`. Una por usuario y entry; al umbral la entry vuelve a `pending`. `neblla.community.shareLink(entryId, { uses?, expiresInDays?, deleteEntry? })` **(CLIENT, login, autor)** → `{ token, key, expiresAt, uses }`. Enlace con clave a una entry; la clave va detrás de `#` en tu URL y nunca llega al servidor (una `publish({ ..., visibility: 'private' })` va cifrada de extremo a extremo y no se lista). `neblla.community.openLink(token, key)` **(CLIENT, login)** → `{ entry, payload }`; gasta un uso y descifra en el navegador. Desconocido, gastado o caducado → `link_not_found`. `neblla.community.deviceLink()` **(CLIENT, login)** → `{ url }`. Enlace (o QR) con tu clave personal para leer tus entries privadas en otro dispositivo tuyo. `neblla.community.links({ entryId? })` **(CLIENT, login)** → tus enlaces `[{ id, entryId, usesLeft, expiresAt, createdAt, deleteEntry }]`, los más nuevos primero. `id` = hash del token (no abre nada). `neblla.community.revokeLink(id)` **(CLIENT, login)** → `{ revoked: true }`. Solo el autor (`not_owner`); desconocido → `link_not_found`. Con `deleteEntry`, revocar el último enlace borra la entry. `neblla.community.cardUrl(entryId)` **(CLIENT)** → `'https://play.neblla.com/c//'`, la tarjeta pública (Open Graph) de una entry publicada; las demás dan 404. `neblla.community.linkedEntry()` **(CLIENT)** → el `?entry=` con el que se llegó desde una tarjeta, o `null`. `neblla.community.counterNotice(entryId, statement)` **(CLIENT, el autor de la entry)** → `{ cases }`: contesta todas las notificaciones formales abiertas sobre esa entry; no la restaura. `neblla.community.noticeUrl(entryId)` → el formulario público de notificación formal para esa entry. `neblla.community.queue({ status?, cursor? })` / `neblla.community.moderate(entryId, { action, reason?, message? })` **(CLIENT, moderadores de la app)** — cola y acciones `approve` | `reject` | `remove` | `restore` | `suspend`. ### Eventos: `neblla.community.on(nombre, cb)` `'updated'` **(CLIENT)** — moderación tocó una entry mía de la comunidad. `{ entry, action }`, `action` ∈ `published` | `pending` | `rejected` | `removed` | `restored` | `modified` | `suspended`; `entry.moderation` trae `{ reason, message }`. `'reportClosed'` **(CLIENT)** — se cerró el caso de una entry que denuncié. `{ entryId, outcome }`, `outcome` ∈ `upheld` | `dismissed`. ## neblla.business — negocio: reservas y bonos (detalle y errores en core.txt) `neblla.business.booking.*` **(CLIENT, login)** — reservas: `services()` (los servicios, sedes y profesionales del negocio), `slots(set, { from, to })` (con `left` y, si el hueco es de una serie que se repite, `series`; `earlyUntil` en una sesión nueva que aún es solo de quien tiene el producto del servicio, `early`), `times(set, { from, to, site, staff, step })` (las horas libres de un servicio `'times'` u `'open'`), `reserve(set, slotId, { for, party })` (en un conjunto que se paga al reservar, `{ booking, held, holdUntil, url }` y abre el pago), `reserveAt(set, { start, site, staff, for, party })` (a una hora libre: la sesión se hace para él, o es su partido abierto), `cancel(set, slotId, { for })`, `mine(set)` (con `room`, el chat de la sesión, y `standing` si vino de una plaza fija), `standing(set?)` (sus plazas fijas en curso), `changeStanding(productId, series, { when: 'now' | 'next' })` (otro de los horarios de su plaza fija, sin pagar), `move(set, slotId, toSlotId, { for })` (pasar una reserva a otra sesión del servicio, si tiene `moveHours`), `closures({ from, to })` (los días cerrados del negocio, para pintarlos; sus sesiones vienen en `slots` como `state: 'cancelled'` con `reason`), `on('update', fn)` (la cita pagada, confirmada o cancelada; o `{ set, standing }` cuando una plaza fija reserva sus casos). `neblla.business.credits.balance(kind)` **(CLIENT, login)** — el saldo de bonos. ## neblla.tools — No-code (detalle en servidor.txt) `neblla.tools.seal(userId)` **(CLIENT)** — nombra en secreto a otro jugador en una partida anónima; el servidor recibe su alias, nunca el nombre. `neblla.tools.anonymize()` **(SERVER)** — partida anónima: cada jugador recibe un alias y `room.js` solo ve alias. `neblla.tools.unmask(userId)` **(SERVER)** devuelve el alias de alguien cuando el juego lo hace público (quórum de dos Fog). `neblla.tools.deck.*` **(BOTH)** — baraja: `create`, `shuffle`, `draw`, `holders`, `add(...).assign/draw`, `muck`, `show`. Sacar sin repetir. `neblla.tools.random.*` **(BOTH)** — azar: `create`, `roll`. Sacar con repetición; cada tirada se anuncia a la sala. `neblla.tools.envelope.*` **(BOTH)** — sobres: `create`, `seal`, `open`. Cualquier JSON, cerrado hasta que se cumple una condición (`mode: 'p2p'` o `'nocode'`). `neblla.tools.zones.*` **(BOTH)** — zonas: `create`, `move`. Quién está con quién, sin que el servidor sepa dónde. `neblla.tools.counter.*` / `neblla.tools.score.*` **(BOTH)** — contadores con tope (`create`, `add`, `read`) y límites de puntuación para rankings (`create`, `submit`). Solo el Fog los crea. Por defecto, en `counter` solo el Fog suma y los jugadores leen (para que sumen ellos, créalo con `player: 'add|read'`); en `score` los jugadores envían. La hora común de la sala, para Fog, es `neblla.room.now()` (en `neblla.room`). ## neblla.world — Mundo, el motor 3D (detalle en mundo.txt) Con el módulo `world`. `neblla.world.create(opts)` **(CLIENT)** → `Promise`: descarga el motor la primera vez. `world.add(spec)`, `world.load(src)` → `Promise`, `world.scatter(modelo, transforms, opts)`, `world.control(personaje)`, `world.camera.use(preajuste, opts)` (`side`, `top`, `iso`, `third`, `first`, `chase`), `world.on(evento, fn)` (`touch`, `enter`, `leave`, `collect`, `fall`, `step`), `world.after(s, fn)`, `world.every(s, fn)`, `world.onFrame(fn)`, `world.state()`, `world.hash()`, `world.start()`, `world.stop()`, `world.destroy()`; el catálogo de piezas: `world.catalog.search(texto, opts)`, `world.catalog.get(id)`, `world.catalog.list(opts)` y `world.load('catalog:')`; el muñeco estándar: `world.doll(cuerpo)` → `Promise` (`{ height, width, legs, torso, arms, head }`), `world.add({ doll })`, `muñeco.play(movimiento)`, `muñeco.stop()`; el muñeco de cada persona (su cuenta Neblla): `neblla.avatar.get(userId?)` → `Promise`, `neblla.avatar.doll(world, userId?)` → `Promise` vestido, `world.avatar(aspecto)`, `muñeco.dress(aspecto)`; un obby desde su JSON (mundo.txt, «Obby con JSON»): `world.obby(json, { doll })` → `Promise` (`juego.start()`, `juego.on('finish', fn)`) y `world.checkObby(json)` → `{ ok, errors, warnings }`; `world.three`, `world.rapier` y `world.sim` para bajar de capa. `neblla.world.engine()` → el módulo del motor. ## Plantilla completa — una sala con árbitro Esqueleto completo y funcional de una app con salas en la que el servidor valida cada jugada (la receta «con árbitro» de salas.txt). Las demás formas parten de aquí, con las adaptaciones del final. ### index.html ```html Mi App
Connecting…
``` ### main.css ```css body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; } #players .player { padding: .25rem .5rem; } #players .player.presenter::before { content: "★ "; } #players .player.me { font-weight: bold; } ``` ### client.js ```javascript let isLobby = true; window.onNeblla = (neblla) => { const $ = (id) => document.getElementById(id); $('status').textContent = 'Connected. In: ' + neblla.room.name; $('roomControls').hidden = false; // login is a feature toggle: if it is off for this app the call is refused // (feature_disabled), which is a setting to change, not a bug to code around. $('loginBtn').onclick = () => neblla.auth.login().catch((e) => { $('status').textContent = 'Sign-in is not available in this app.'; console.warn('login refused', e); }); $('logoutBtn').onclick = () => neblla.auth.logout(); $('createRoomBtn').onclick = async () => { // rooms.book reserves the code but does NOT enter; rooms.join puts you in. // The room starts in `gathering` — nothing in server/room.js runs until // room.start() is called (by the presenter here, or by server/gathering.js). const code = await neblla.rooms.book({ length: 5 }); await neblla.rooms.join(code); $('status').textContent = 'Room ' + code + ' — waiting for players'; }; $('startRoomBtn').onclick = () => neblla.room.start(); $('leaveRoomBtn').onclick = () => neblla.room.leave(); $('joinRoomBtn').onclick = () => { const code = $('roomCodeInput').value.trim(); if (code) neblla.rooms.join(code); }; // A mistyped code is the most likely thing to happen. Handle it: without this // listener a failed join looks like nothing happening at all, and there is no // runtime log to debug it with. neblla.rooms.on('joinError', (event) => { $('status').textContent = 'Could not join that room. Check the code.'; console.warn('rooms.join failed', event); }); // The SDK's general error channel. Without it, a spent daily quota just makes // every event stop working with no signal. neblla.app.on('error', (event) => { if (event.code === 'quota_exceeded') { $('status').textContent = 'This app is out of budget for today.'; } }); neblla.room.on('joined', (event) => { isLobby = event.isLobby; $('status').textContent = 'In room: ' + event.room.name; $('leaveRoomBtn').hidden = isLobby; renderPlayers(); if (isLobby) { $('game').innerHTML = ''; } else { // render game board (neblla.state.get() has the state if the game is running) } }); // Authoritative state travels ONLY through state.set → state.on. // Never publish state as a generic message (see servidor.txt). neblla.state.on((state) => { // render(state); }); // Messages from server/room.js (from === 'server') are for one-off // announcements ("wrong answer", "your turn"), never for the state itself. neblla.message.on(({ from, data }) => { if (from !== 'server') return; // if (data.type === 'toast') showToast(data.text); }); neblla.room.on('userJoined', renderPlayers); neblla.room.on('userLeft', renderPlayers); neblla.room.on('publicInfo', renderPlayers); neblla.room.on('presenter', renderPlayers); // Only the presenter may start the room. const syncStart = () => { $('startRoomBtn').hidden = !(neblla.room.me?.isPresenter && !isLobby); }; neblla.room.on('joined', syncStart); neblla.room.on('presenter', syncStart); neblla.room.on('started', () => { $('startRoomBtn').hidden = true; }); neblla.auth.on('login', () => { $('loginBtn').hidden = true; $('logoutBtn').hidden = false; }); neblla.auth.on('logout', () => { $('loginBtn').hidden = false; $('logoutBtn').hidden = true; }); function renderPlayers() { const el = $('players'); el.innerHTML = ''; neblla.room.users.forEach(u => { const div = document.createElement('div'); div.className = 'player' + (u.isPresenter ? ' presenter' : '') + (u.isMe ? ' me' : ''); div.textContent = (u.publicInfo.name || u.id.slice(0, 8)) + (u.isMe ? ' (you)' : ''); el.appendChild(div); }); } }; ``` ### room.js ```javascript // Top-level. neblla en scope global. Sin DOM. Sin neblla.auth ni neblla.room.me. let gameState = neblla.state.get() || initialState(); neblla.message.on((m) => { if (m.to !== 'server') return; // solo lo que un cliente mandó con message.to('server', …) if (m.data.type === 'action') { gameState = applyAction(gameState, m.user.id, m.data); neblla.state.set(gameState); } }); neblla.room.on('userJoined', () => { if (gameState) neblla.state.set(gameState); }); ``` ### Adaptaciones Cada una tiene su receta completa, con código, en salas.txt («Recetas: tres formas de sala»). - **Pantalla y mandos**: en `client.js` ramifica por `neblla.room.me.isBoard || (!neblla.room.board && neblla.room.me.isPresenter && !isLobby)` — un `isPresenter` pelado solo es correcto mientras no haya `board` asignado, y mueve la pantalla de anfitrión a un móvil cuando se pierde el presentador. Ver empezar.txt. - **Todos a la vez**: `room.js` vacío. Todos los peers iguales. `neblla.message.send` para cambios locales, aplicar entrantes en `neblla.message.on`. - **Solo + save**: `room.js` vacío, sin salas. Solo `neblla.db.save()` y `neblla.db.data`. ## Todos los códigos de error, en un sitio Los errores te llegan por tres vías, y saber cuál es cuál ahorra mucho adivinar. Como **rechazo de la promesa** de la llamada que hiciste (casi toda la API de comunidad y de salas). Como **evento con nombre propio** (`neblla.rooms.on('joinError')`, `neblla.room.on('dataError')`, `neblla.room.on('fileError')`, `neblla.auth.on('expired')`, `neblla.app.on('maintenance')`). Y el **evento general `neblla.app.on('error')`**, que lleva `{ code }` para los casos en que el servidor te bloquea y tus eventos se quedarían muertos sin más: `quota_exceeded` (el presupuesto del día se agotó; se resetea a las 00:00 UTC, así que no reintentes en bucle) y `app_maintenance`; y `store_unavailable` cuando un login no se puede hacer porque la base de datos propia de la app no responde. Todo lo demás es un código de rechazo en la llamada que lo provocó: - **Sesión y features:** `store_unavailable` (la base de datos propia de la app no responde: no se puede hacer login, ni comprar ni gastar, hasta que vuelva), `login_required` (hace falta usuario identificado), `feature_disabled` (ese módulo está apagado en la app; el error lo nombra en `module` — es un ajuste que cambiar, nunca algo que rodear), `user_banned`, `community_muted`, `user_terminated`. - **Salas:** `not_in_room`, `not_authorized`, `rooms_data_loss` (el cambio destruiría datos de usuarios — pregunta al dev antes de repetirlo con `confirmRoomsChange`), `db_bytes_exceeded`. - **Guardar (`db.save()`, también en `db.on('error')`):** `save_too_large` (lleva `bytes` y `max`), `invalid_save`, `invalid_at`, `login_required`, `save_loop`; `quota_exceeded` no se pierde: espera en el navegador. La base de datos llena no es un error: lo que guarda un usuario que ya estaba dentro espera en la cola (`pending`), filas de tablas incluidas. - **Comunidad:** `title_required`, `contentType_required`, `payload_required`, `payload_too_large` (lleva `bytes` y `max`), `category_not_allowed` / `content_type_not_allowed` (llevan `allowed`), `too_many_tags` (lleva `max`, que es 20), `entry_limit_reached`, `moderators_only`, `not_owner`, `entry_not_found`, `parent_not_found`, `already_suspended`, `own_entry`, `invalid_reason` (lleva `allowed`), `message_required`, `community_storage_exceeded` (lleva `current` y `cap`), `link_not_found`, `rules_declined`, `popup_blocked` (lleva `url`). - **Lo de otro usuario (`db.dataOf`, `db.loadOf`):** `not_visible`, `not_friends`, `user_not_found`, `unknown_table`. - **Intercambios (`db.offer` y los demás):** `trade_off`, `not_friends`, `self`, `not_owned`, `not_tradable` (lleva `table` o `product`), `invalid_offer`, `invalid_amount`, `invalid_expires`, `insufficient_amount`, `too_many_offers`, `offer_not_found`, `offer_expired`, `offer_closed` (lleva `status`); al aceptar, también `offer_changed`, `too_many_rows`, `already_owned`. - **Tablas que solo escribe Neblla y sorteos (`db.draw`):** `table_read_only` (lleva `table`), `unknown_draw`, `no_free_open` (lleva `nextAt`), `not_enough_credits` (lleva `cost` y `nextFreeAt`), `invalid_pay`, `too_many_rows`, `draw_table_not_read_only` (la tabla dejó de ser de Neblla); al declararlos, `invalid_draws` (lleva `field`, `draw` y `reason`) e `invalid_draw_name`. - **Baraja de cada jugador (`tools.deck` con `fromRows`):** `invalid_from_rows`, `table_not_read_only`, `not_owned` (lleva `id`), `repeated_card`, `invalid_deck_size` (lleva `min` y `max`), `already_brought`, `not_brought`, `not_in_hand`, `deck_empty`. - **Compras:** `insufficient_amount`, más los rechazos de `neblla.shop.buy()` de arriba (`login_required`, `feature_disabled`). - **Buzón:** `communications_full` (lleva `cap` y `current`). - **Puntuaciones (`neblla.scores`):** `unknown_board`, `runs_per_day` (lleva `max`), `data_too_large`, `invalid_period`, `login_required`; un envío que no entra no rechaza: resuelve con `{ ok: false, reason }`. - **Medios (`neblla.media`, `neblla_media`):** `collection_not_found`; al subir y ordenar, `media_storage_off`, `media_quota_exceeded` (lleva `scope`, `used`, `limit`, `needs`), `media_too_large` (lleva `max`), `media_type_not_supported`, `media_fetch_failed` (lleva `reason`), `collection_exists`, `collection_full`, `collection_not_empty`, `media_not_found`, `invalid_fields`; al reducir, `invalid_resize`, `ids_or_collection_required`; originales privados (C230), `media_private_storage_off`, `invalid_public_width`, `not_private`, `product_cannot_unlock`, `login_required`, `not_entitled` (lleva `unlockedBy`), `no_credits_unlock_it`, `kind_required` (lleva `kinds`), `kind_does_not_unlock`, `not_enough_credits`. - **Tienda, unidades y pedidos (`neblla.shop`):** `sold_out` (lleva `left`), `country_required` (lleva `zones`), `country_not_shipped` (lleva `countries`); `neblla_create_product` / `neblla_update_product`, `invalid_stock`, `invalid_shipping`; `neblla_orders`, `order_not_found`, `invalid_status`, `invalid_transition`, `tracking_required`, `invalid_tracking_url`. - **Config y plan, desde MCP:** `git_conflict` (la app sigue a un repo y ese fichero cambió en GitHub desde la última sincronización), `not_linked`, `foreign_commit`, `unknown_branch`, `no_files`. Dos reglas que valen para cualquier código: no rodees nunca un `feature_disabled`, y no reintentes nunca un `quota_exceeded`.