Oasis 0.9.5 con Karvan para escritorio

Rama de desarrollo experimental sobre el Oasis de epsylon, sin afiliacion con el
proyecto original. Base: 0.9.5.

Trae el modulo Karvan —salas efimeras en memoria con autodestruccion por TTL, chat
que funciona sin JavaScript y llamadas de audio y video sobre WebRTC— con el enlace
en el menu lateral por el mismo patron que el resto de modulos: renderKarvanLink
calcado de renderPollsLink, entrada en modules_view y en la lista de /modules.

Los estilos del modulo van en su propia hoja, styles/karvan.css, siguiendo el patron
de highlight.css: asi se ve igual con cualquier tema, sin depender del tema movil.

Sin servidores ICE de terceros: no hay STUN de fabrica, porque un STUN aprende la IP
publica y el momento de cada llamada. Solo candidatos host salvo que se configure un
TURN propio en oasis-config.json, con credenciales efimeras por el mecanismo REST de
coturn.

El codigo propio vive separado —views/fork/, backend/fork_routes.js,
models/karvan_model.js y translations/fork/— de modo que sobre los ficheros de
upstream solo hay enganches de una linea y cada version nueva se integra sin
arrastrar nada.

Respecto al arbol de Android: fuera el wrapper y main.js, que es su arranque; el
tema vuelve a Dark-SNH y se restauran los modulos que en movil se recortan por peso.

Verificado arrancando sin OASIS_MOBILE: nueve rutas OK, menu lateral de upstream con
27 grupos y el enlace de Karvan, cero rastro de la interfaz movil (ni hexagonos, ni
topbar, ni barra inferior), y una sala creada y servida.
This commit is contained in:
SITO 2026-08-19 00:35:08 +02:00
commit ddd2787b73
286 changed files with 157145 additions and 0 deletions

View file

@ -0,0 +1,62 @@
# Guía de desarrollo de Oasis
Esta carpeta documenta cómo está construido Oasis por dentro, para que puedas
entenderlo y extenderlo (por ejemplo, creando un **módulo de juegos** nuevo).
Está pensada para leerse en orden, pero cada archivo es autónomo.
## Índice
1. **[01-red-ssb.md](01-red-ssb.md)** — Cómo funciona Oasis **a nivel de red**:
Secure Scuttlebutt (SSB), secret-stack, multiserver, muxrpc, EBT, blobs,
federación P2P, puertos y cifrado.
2. **[02-arquitectura-software.md](02-arquitectura-software.md)** — La
**arquitectura de software** del backend: el patrón MVC casero
(models / views / backend.js), el stack de dependencias, el "cooler", el
flujo de una petición y cómo se interrelacionan los archivos.
3. **[03-modulos-y-juegos.md](03-modulos-y-juegos.md)** — Cómo está construido
el **módulo de juegos** actual y la anatomía de un módulo en general.
4. **[04-tutorial-modulo-desde-cero.md](04-tutorial-modulo-desde-cero.md)** —
**Tutorial paso a paso** para crear un módulo nuevo desde cero (caso práctico:
un módulo de juegos propio), con todos los puntos de integración.
5. **[05-mapa-funciones.txt](05-mapa-funciones.txt)** — **Gran mapa ASCII** de
cómo se relacionan las funciones lógicas entre archivos, la persistencia
(SSB) y la media (blobs).
## Conceptos en 30 segundos
Oasis es un **cliente web de Secure Scuttlebutt (SSB)**. SSB es un protocolo
social **P2P, distribuido y federado**: no hay servidor central; cada nodo tiene
una copia local de un *log append-only* firmado criptográficamente, y los nodos
se replican entre sí por *gossip*.
Oasis se compone de **dos procesos**:
```
┌─────────────────────────┐ ┌──────────────────────────┐
│ SBOT (nodo de red SSB) │ <─────> │ FRONTEND WEB (Koa/HTML) │
│ src/server/SSB_server │ unix │ src/backend/backend.js │
│ habla con otros peers │ socket │ habla con tu navegador │
└─────────────────────────┘ └──────────────────────────┘
puerto 8008 (SSB) puerto 3000 (HTTP)
```
- El **sbot** (`src/server/SSB_server.js`) es el nodo SSB: se conecta a otros
peers, replica feeds, sirve y descarga blobs. → [01-red-ssb.md](01-red-ssb.md)
- El **frontend** (`src/backend/backend.js`) es un servidor HTTP en Koa que
renderiza HTML puro (sin JS de navegador) y se conecta al sbot como **cliente
muxrpc** por un socket Unix local. → [02-arquitectura-software.md](02-arquitectura-software.md)
El patrón para **cada funcionalidad** ("módulo") es siempre el mismo:
```
navegador → backend.js (ruta) → *_model.js (lee/escribe SSB) → *_view.js (HTML) → navegador
```
> Las rutas, líneas y nombres citados en estos documentos corresponden a la
> versión 0.8.1 del código. Si editas el código, vuelve a comprobar los números
> de línea con `grep`.

View file

@ -0,0 +1,243 @@
# 1. Oasis a nivel de red (Secure Scuttlebutt)
Oasis es un cliente/servidor de **Secure Scuttlebutt (SSB)**. Toda la "red" de
Oasis se construye sobre la pila clásica del ecosistema SSB:
`secret-stack` + `ssb-db` + un montón de plugins.
Hay una separación clara en **dos procesos**:
- **Backend SSB (sbot)**`src/server/SSB_server.js`: el nodo de red propiamente
dicho. Es quien habla con otros peers.
- **Frontend web (GUI Koa)**`src/backend/backend.js`: un servidor HTTP que
actúa como **cliente muxrpc** del sbot, conectándose por un socket Unix local.
```
RED SSB (otros peers Oasis, misma SHS cap)
net:IP:8008~shs:<pubkey> · ssb-lan (UDP) · ssb-onion (Tor)
┌─────────────────────────────────────────────┐
│ SBOT (src/server/SSB_server.js) │
│ peer <-> MULTISERVER <-> SECRET-STACK (SHS) │
<-> MUXRPC <-> PLUGINS │
└─────────────────────────┬───────────────────┘
│ unix socket ~noauth (muxrpc local, sin SHS)
┌─────────────────────────────────────────────┐
│ FRONTEND (src/backend/backend.js, Koa) │
│ cooler = src/client/gui.js → ssb-client │
│ app.listen({host, port:3000}) │
└─────────────────────────┬───────────────────┘
Navegador http://localhost:3000
```
---
## 1.1 Arranque de red
### Scripts de arranque
- **`install.sh`**: instala Node 22 y hace `npm install` en `src/server`
(línea 23). Opcionalmente descarga modelos de IA. No toca la red.
- **`oasis.sh`** es el lanzador. Modos relevantes:
- `server` / `pub` (líneas 61-69): ejecuta `node SSB_server.js start` — **solo
el sbot headless**, sin interfaz web.
- `gui` o por defecto (líneas 73-81): ejecuta `node backend.js` — el frontend.
- comandos pub `whoami|invite|name|announce|follow|status|gossip`
(líneas 70-72) → delegan en `scripts/oasis-pub.js`.
- **`src/server/package.json`**, script `start`:
`npm run start:ssb && sleep 10 && npm run start:backend`.
Arranca el sbot en segundo plano, **espera 10 s** (para que el socket Unix
exista) y luego arranca el frontend.
### Qué hace `SSB_server.js` paso a paso
(`src/server/SSB_server.js`)
1. **Líneas 7-11**: carga `secret-stack`, `ssb-caps` (la capability SHS),
`ssb-db`, la config (`./ssb_config`) y utilidades de metadatos.
2. **Líneas 13-52** (IIFE): *monkey-patch* de `console.error`/`console.warn`
para silenciar ruido de red — rechazos de handshake por SHS cap incorrecta,
excepciones EBT, etc. Un rechazo se convierte en un log limpio:
`[ts] REJECTED <ip:port> (wrong SHS cap)`.
3. **Líneas 56-81**: construye el servidor con
`SecretStack({ caps }).use(plugin).use(plugin)...` encadenando todos los
plugins (ver tabla abajo).
4. **Líneas 83-86**: si **no** es pub, carga `ssb-lan` (descubrimiento LAN por
UDP) y el router LAN local `./lanRouter`.
5. **Líneas 88-98**: lógica de `autofollow`; carga `ssb-autofollow` si hay feeds.
6. **Líneas 123-196** (`argv[0] === 'start'`): instancia el servidor, escribe
`manifest.json` (el catálogo de métodos muxrpc remotos), monta una barra de
progreso de replicación, imprime metadatos y **escucha eventos del hub de
conexiones** (`server.conn.hub().listen()`) para loguear `CONNECTED` /
`DISCONNECTED`.
7. **Líneas 198-202**: exporta `{ config, server, open() }` — esto permite a la
GUI reusar el servidor **in-process** si corre en el mismo proceso.
### Plugins secret-stack cargados (líneas 56-98)
| Plugin | Función de red |
|---|---|
| `ssb-db` | base de datos *append-only* de feeds |
| `ssb-master` | identidad maestra / auth local |
| `ssb-gossip` | gossip clásico (legacy) de peers |
| `ssb-ebt` | **Epidemic Broadcast Trees** — replicación gossip eficiente |
| `ssb-friends` | grafo social: hops, follows/blocks |
| `ssb-blobs` | transferencia de ficheros binarios |
| `ssb-meme` | blobs de imágenes/memes |
| `ssb-conn` | gestor de conexiones moderno (hub, db, staging) |
| `ssb-box` | cifrado de mensajes privados (box1) |
| `ssb-search`, `ssb-private`, `ssb-friend-pub` | búsqueda, PM, pub de amigos |
| `ssb-invite` / `ssb-invite-client` | invites (servidor si es pub, cliente si no) |
| `ssb-replication-scheduler`, `ssb-partial-replication` | planificación de replicación |
| `ssb-onion` | transporte Tor |
| `ssb-unix-socket` | **socket Unix local** para clientes (la GUI) |
| `ssb-no-auth` | conexiones locales sin handshake SHS |
| `ssb-about`, `ssb-backlinks`, `ssb-links`, `ssb-tangle`, `ssb-query` | índices de vistas |
| `ssb-lan` + `lanRouter` | descubrimiento LAN (solo si no es pub) |
> Nota: la replicación NO usa `ssb-replicate` directamente; la orquestan
> `ssb-ebt` + `ssb-replication-scheduler` + `ssb-friends`.
---
## 1.2 El protocolo SSB en términos del proyecto
- **secret-stack + handshake SHS (capability).** `SecretStack({ caps })` crea el
nodo. La `caps.shs` está **fijada en `src/configs/server-config.json`**
(líneas 5-7): `"H5EC+V5BU9s0lWxCkt4z8a095Sj8a6TgiLKPYi1JD7s="`. Es la **clave
de capacidad de red** (Secret Handshake): dos peers solo completan el
handshake si comparten *exactamente* esta cap. Actúa como "contraseña de red":
**Oasis forma su propia red SSB privada**, distinta de la red SSB pública.
- **multiserver (transportes).** Capa de transporte abstracta. La config
`connections` (`server-config.json`, líneas 29-60) define:
- **incoming.net**: `transform: "shs"`, `port: 8008` → TCP cifrado con SHS.
- **incoming.unix**: `transform: "noauth"` → socket Unix local sin handshake,
para que el frontend se conecte como cliente privilegiado.
- **outgoing.net**: `transform: "shs"`.
- Una dirección multiserver típica: `net:IP:8008~shs:<pubkey-base64>`.
- **muxrpc (RPC sobre el stream).** Una vez establecido el stream SHS, `muxrpc`
multiplexa llamadas RPC asíncronas y *pull-streams* sobre la misma conexión.
El `manifest.json` describe qué métodos remotos existen (`sync`/`async`/
`source`). Es como el frontend invoca `ssb.publish(...)`, `ssb.blobs.get(...)`,
etc.
- **EBT (`epidemic-broadcast-trees` + `ssb-ebt`).** El algoritmo de replicación
gossip. Cada peer anuncia el `seq` (reloj vectorial) de los feeds que tiene;
el otro envía solo los mensajes que faltan, formando árboles de difusión
epidémica que evitan duplicados.
- **Identidad de los peers.** Cada peer es un **feed id ed25519**:
`@<base64>.ed25519`. En las direcciones multiserver, el sufijo
`~shs:<pubkey>` identifica criptográficamente al peer durante el handshake.
---
## 1.3 Federación / P2P
- **Descubrimiento LAN** (`src/server/lanRouter.js`): plugin local custom.
`startRouter` (líneas 55-64) arranca `ssb.lan.start()` (broadcast UDP) y
consume `ssb.lan.discoveredPeers()`. Cada peer descubierto pasa por
`handleDiscovery``stagePeer` (líneas 15-45): lo *stagea* en
`ssb.conn.stage(...)` (vía moderna) con fallback a `ssb.gossip.add(...)`
(legacy); si `eagerReplicate`, fuerza `ebt.request`. Respeta el flag
`lanBroadcasting` de `oasis-config.json`.
- **Pubs / invites.** En modo pub se carga `ssb-invite` (servidor); en cliente,
`ssb-invite-client`. Los comandos `oasis.sh invite|announce|follow`
(→ `scripts/oasis-pub.js`) permiten a un pub publicar su dirección y generar
códigos de invitación. Un invite contiene `host:port:pubkey~seed`; al
canjearlo, el peer se conecta y el pub le sigue de vuelta (`ssb-friend-pub`).
- **Replicación por hops.** `ssb-friends` define el grafo social.
`server-config.json` fija `friends.hops: 2` → el nodo replica los feeds de la
gente que sigues y de la gente que ellos siguen (amigos de amigos). El
`ssb-replication-scheduler` decide qué feeds pedir por EBT según ese grafo.
- **"Distributed not decentralized".** No hay servidor central ni DHT global.
Cada nodo Oasis tiene su **propia copia local** de la base de datos
(logs *append-only* firmados con ed25519) y solo replica los feeds dentro de
su radio social (hops) más los descubiertos por LAN. La red se federa por
gossip entre peers que comparten la misma SHS cap. Es **distribuida** (datos
replicados en muchos nodos autónomos, funciona offline) pero **no
descentralizada** como una blockchain (no hay consenso global ni vista única;
cada quien ve solo su subgrafo). El flag `wish` de `oasis-config.json`
(`whole` / `mutuals` / `only-lan`) controla el alcance de replicación deseado.
---
## 1.4 Puertos y endpoints
| Qué | Dónde | Puerto / dirección |
|---|---|---|
| muxrpc SSB (sbot) — otros peers se conectan aquí | `server-config.json` incoming.net | **8008/TCP** transform `shs` |
| Socket Unix local — para el frontend | incoming.unix | `unix:<ssb.path>/socket~noauth:<pubkey>` |
| Frontend web (Koa) — tu navegador | `oasis_client.js` / `middleware.js:180` | **3000** host `localhost` (configurable con `--host`/`--port`) |
**Cómo se conecta el frontend al sbot** (el "cooler", `src/client/gui.js`):
1. Intenta `require('../server/SSB_server')` y reusar el `server` **in-process**
si existe (sin socket, uso directo del objeto).
2. Si no, construye `remote = unix:<path>/socket~noauth:<pubkey>` y se conecta
con **`ssb-client`** (muxrpc por el socket Unix `noauth`).
3. `attemptConnectionWithBackoff`: reintentos con backoff exponencial (de ahí el
`sleep 10` del arranque).
4. Expone `cooler.open()` / `cooler.close()`. **Todos los modelos** del backend
reciben este `cooler` y hacen `await cooler.open()` para obtener el cliente
muxrpc.
> Para exponer la GUI en un VPS se usa `--host=0.0.0.0`.
---
## 1.5 Blobs (ficheros / imágenes)
- Plugin `ssb-blobs` (+ `ssb-meme`). Tamaño máximo en `ssb_config.js`: 50 MB.
- Un blob se referencia por hash: `&<sha256-base64>.sha256`. **Los mensajes del
log solo llevan el hash**, no el binario.
- Flujo en `src/backend/blobHandler.js`:
- **Subida**: `ssbClient.blobs.add(...)` recibe el contenido por pull-stream y
devuelve el ref `&...sha256`.
- **Descarga por red**: `blobs.has(id)` comprueba si ya está local; si no,
`blobs.want(id)` **lo pide a los peers conectados** (gossip de "want/has"
sobre muxrpc), y `blobs.get(id)` lo lee como stream.
Los blobs viajan sobre la misma conexión SHS/muxrpc, fuera del log append-only.
---
## 1.6 Caps / cifrado
- **`ssb-caps`** define las capabilities de protocolo (shs, sign, invite). La
**shs cap real** está fijada en `server-config.json` → red privada de Oasis.
- **Handshake SHS**: autenticación mutua por ed25519 + confidencialidad del
stream de transporte.
- **`ssb-box`**: cifrado de **mensajes privados box1** (hasta ~7 destinatarios,
cifrado por clave pública de cada feed). `ssb-private` expone publish/read.
- **Grupos privados ("tribes").** No se usa `box2`/`ssb-tribes`. En su lugar
Oasis implementa cifrado **propio a nivel de aplicación** en
`src/models/crypto.js`, instanciado por dominio en `backend.js`
(`tribeCrypto`, `chatCrypto`, `padCrypto`, `mapCrypto`, `calendarCrypto`,
`eventCrypto`, `forumCrypto`). Los grupos se cifran con esas claves derivadas
y se almacenan/replican como mensajes normales del feed.
---
## 1.7 Archivos clave de la capa de red
| Archivo | Rol |
|---|---|
| `src/server/SSB_server.js` | Construye el sbot y carga los plugins (56-98) |
| `src/server/ssb_config.js` | Fusiona `server-config.json`; fija `blobs.max=50MB` |
| `src/server/ssb_metadata.js` | Deriva e imprime puerto SSB, hops, LAN, feed ID |
| `src/server/lanRouter.js` | Descubrimiento/staging de peers LAN |
| `src/configs/server-config.json` | `caps.shs`, `pub`, `friends.hops`, `connections` (puertos) |
| `src/configs/oasis-config.json` | `lanBroadcasting`, `wish`, `pmVisibility`, módulos |
| `src/client/gui.js` | El "cooler": cliente muxrpc por socket Unix noauth |
| `src/backend/blobHandler.js` | add/has/want/get de blobs |
| `scripts/oasis-pub.js` | Comandos pub (invite/announce/follow) |
Siguiente: **[02-arquitectura-software.md](02-arquitectura-software.md)**.

View file

@ -0,0 +1,350 @@
# 2. Arquitectura de software del backend
Oasis usa un patrón **MVC casero** (sin framework MVC):
```
src/backend/backend.js → el ROUTER monolítico (Koa, ~7250 líneas, ~472 rutas)
src/models/*_model.js → la lógica de DATOS (lee/escribe SSB)
src/views/*_view.js → el RENDER de HTML (con hyperaxe)
```
Flujo de una funcionalidad:
```
navegador → backend.js (ruta) → xModel.metodo() (SSB) → xView(data) (HTML) → ctx.body
```
---
## 2.1 Stack de software (dependencias clave)
Todas las dependencias están en `src/server/package.json`. Importante: casi
todos los `require` usan **ruta relativa explícita** al node_modules del
servidor, p.ej. `require("../server/node_modules/@koa/router")`.
**Capa web / HTTP**
- **`koa`** — servidor HTTP base. Se instancian varias apps Koa en
`src/client/middleware.js` (principal + sub-apps para estáticos).
- **`@koa/router`** — enrutado. `backend.js:442` instancia
`const router = new koaRouter()`. Toda la API es **una única cadena fluida**
`router.param(...).get(...).post(...)` (líneas ~1270-7124).
- **`koa-static` / `koa-mount`** — sirven assets estáticos (`/assets`,
`/maptiles`, `/game-assets`, `/js`...) montados en `middleware.js`.
- **`koa-body`** — parseo de `multipart/form-data` y POST. Se aplica por ruta:
`.post('/votes/create', koaBody(), ...)`.
- `express` y `cors` figuran como dependencias pero **el flujo principal es
Koa**; Express no se usa en el router.
**Motor de plantillas (vistas)**
- **`hyperaxe`** — hyperscript → HTML. **No hay archivos `.html`**; el HTML se
construye programáticamente desde JS: `div(...)`, `nav(...)`, `form(...)`...
Cada elemento expone `.outerHTML`.
- **`ssb-markdown` + `ssb-msgs` + `ssb-ref`** — render de Markdown SSB con
resolución de menciones/enlaces internos (`src/views/markdown.js`).
- **`highlight.js`, `qrcode`, `moment`, `pretty-ms`** — sintaxis, QR, fechas.
- **`dompurify` + `jsdom` + `is-svg`** — saneado anti-XSS
(`src/backend/sanitizeHtml.js`).
**Streams SSB**
- **`pull-stream`** — el paradigma de streams de SSB. Es el mecanismo central de
lectura: `pull(source, pull.filter(...), pull.collect(cb))`.
- Auxiliares: `pull-paramap` (map asíncrono), `pull-sort`, `pull-abortable`
(cancelar streams `live`), `pull-cat`, `pull-many`, `pull-pushable`.
**Cliente / servidor SSB**
- **`ssb-client`** — cliente RPC muxrpc para conectar al sbot (en `gui.js`).
- **`ssb-config`, `ssb-keys`, `ssb-ref`** — config, claves ed25519, validación
de identificadores (`@feed`, `%msg`, `&blob`).
- **`secret-stack` + `ssb-caps` + `ssb-db`** y los plugins SSB → ver
[01-red-ssb.md](01-red-ssb.md).
**Otros**: `module-alias`, `lodash`, `minimist`, `open`, `archiver`,
`file-type`, `pdfjs-dist`, `openpgp`, `axios`, `node-llama-cpp` +
`@xenova/transformers` (módulo IA local).
---
## 2.2 Flujo de una petición
**a) Construcción del servidor** — `src/client/middleware.js`: la función
exportada `({host, port, middleware, allowHost})` crea la app Koa, monta
estáticos, fija cabeceras de seguridad (CSP, `X-Frame-Options`...), valida el
host, aplica los middlewares recibidos y finalmente `app.listen({host, port})`
(línea 180). En `backend.js:7239`:
`const app = http({ host, port, middleware, allowHost: config.allowHost })`.
**b) Cadena de middleware** — `backend.js:7126-7238`: incluye (1) bloqueo de
no-GET en modo público, (2) `setLanguage(...)` por cookie, (3) gate de
indexación (muestra `indexingView` si la base no está sincronizada), (4) refresco
de estado compartido cada 60 s, y por último `routes` (las rutas del router).
**c) Ruta GET — ejemplo agenda** (`backend.js:1977-1982`):
```js
.get('/agenda', async (ctx) => {
const filter = qf(ctx);
let data = await agendaModel.listAgenda(filter); // MODELO: lee SSB
if (Array.isArray(data)) data = await applyListFilters(data, ctx);
ctx.body = await agendaView(data, filter); // VISTA: genera HTML
})
```
`agendaModel.listAgenda` abre el cliente (`cooler.open()`), lee el log
(`createLogStream`), agrega ítems y devuelve datos. `agendaView` los recibe y
construye HTML con hyperaxe, envuelto en `template(...)`. El HTML se asigna a
`ctx.body` y Koa lo sirve.
**d) Ruta POST — ejemplo votos** (`backend.js:5572`):
```js
.post('/votes/create', koaBody(), async ctx => {
const b = ctx.request.body;
const parsedOptions = b.options ? b.options.split(',')... : defaultOptions;
await votesModel.createVote(stripDangerousTags(b.question), b.deadline, parsedOptions, ...);
ctx.redirect(safeReturnTo(ctx, '/votes?filter=mine', ['/votes']));
})
```
El POST **sanea** la entrada (`stripDangerousTags`), llama al modelo que
**publica un mensaje SSB**, y **redirige** (patrón Post/Redirect/Get). El GET
posterior vuelve a leer del modelo y renderiza.
---
## 2.3 Conexión al servidor SSB: el "cooler"
El **cooler** es la abstracción que gestiona la conexión al sbot. Se define en
`src/client/gui.js` (importado en `backend.js:441` como
`const ssb = require("../client/gui")`) y se instancia en `backend.js:540`:
```js
const cooler = ssb({ offline: config.offline, port: config.port,
host: config.host, isPublic: config.public });
```
- Si existe un sbot **en el mismo proceso** (`internalSSB`), `cooler.open()` lo
devuelve directamente sin RPC.
- Si no, conecta por muxrpc vía socket Unix `noauth` con backoff exponencial.
**Patrón de uso en todo el código**:
`const ssb = await cooler.open();` y luego se usan los métodos muxrpc:
| Método | Para qué |
|---|---|
| `ssb.createUserStream({id, reverse, limit})` | feed de un usuario |
| `ssb.createLogStream({reverse, limit})` | log global (lo más usado) |
| `ssb.query.read({query:[{$filter:{...}}]})` | consultas ssb-query |
| `ssb.messagesByType({type, private})` | mensajes por tipo |
| `ssb.backlinks.read({query})` | referencias/backlinks |
| `ssb.get(id, cb)` | un mensaje por id |
| `ssb.publish(content, cb)` | **publicar un mensaje** |
| `ssb.blobs.want/get/add` | blobs |
| `ssb.friends.isFollowing/isBlocking/graph` | grafo social |
| `ssb.conn.peers / ssb.status() / ssb.progress()` | estado de red |
---
## 2.4 Patrón de un MODELO
Todos los modelos siguen la **factory pattern**:
`module.exports = ({ cooler, isPublic, ...deps }) => ({ ...métodos })`. Reciben
el `cooler` por inyección desde `backend.js` (líneas 553-602). Muchos cachean el
cliente:
```js
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
```
**Publicar (escribir)** — `ssb.publish(content, cb)` con un campo `type`.
Ejemplo `votes_model.js` (`createVote`):
```js
const content = {
type: 'votes', question, options, deadline, createdBy: userId,
status: 'OPEN', votes: {...}, totalVotes: 0, createdAt: new Date().toISOString()
};
return new Promise((res, rej) =>
ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
```
**Leer** — `pull-stream` + `collect`:
```js
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, results) => err ? reject(err) : resolve(results))
);
```
Luego se procesa en memoria: se construye un índice, se filtra por `type`, se
ordena.
**Borrar / editar — modelo de tombstone** (el log es *append-only*: nada se
borra físicamente):
- **Borrado lógico**: se publica `{ type:'tombstone', target:<msgId>, deletedAt,
author }`.
- **Validación** (`src/models/tombstone_validator.js`,
`buildValidatedTombstoneSet`): solo acepta el tombstone si **el autor del
tombstone coincide con el autor del mensaje objetivo** — evita borrados por
terceros.
- **Edición**: se publica una versión nueva con `replaces: <idAnterior>` y se
lapida la anterior, formando una cadena de versiones; al leer se sigue la
cadena hasta la "punta".
- Las comprobaciones de autoría (`if (c.createdBy !== userId) throw`) se hacen en
el modelo.
**Tipos de mensaje SSB** (campo `content.type`): los nativos SSB `post`, `vote`
(likes), `contact` (follow/block), `about` (perfil), y los **propios de Oasis**:
`votes`, `tombstone`, `task`, `event`, `calendar`, `transfer`, `tribe`,
`market`, `report`, `job`, `project`, `gameScore`, etc.
> **`main_models.js`** es el modelo núcleo: exporta `about`, `blob`, `friend`,
> `meta`, `post`, `vote`, `spreads`, `lifetime`. Su función `transform()`
> enriquece cada mensaje con metadatos (autor, avatar, votos, timestamps) antes
> de pasarlo a las vistas.
---
## 2.5 Patrón de una VISTA
Las vistas exportan funciones que reciben datos del modelo y devuelven HTML.
Patrón típico:
```js
const { div, h2, p, section, button, form } = require("../server/node_modules/hyperaxe");
const { template, i18n, userLink } = require('./main_views');
exports.agendaView = async (data, filter) =>
template(i18n.agendaTitle, section( h2(i18n.agendaTitle), /* ... */ ));
```
**Layout común — `main_views.js`**:
- `template(titlePrefix, ...elements)` construye el documento completo:
`html → head (title, CSS, favicon) → body (header, sidebar de navegación,
main-content)`. Se exporta como `exports.template`.
- **Navegación**: helper `navLink({href, emoji, text, current})` genera cada
`<li><a>`. `navGroup({id, emoji, title})` agrupa enlaces en secciones
colapsables del menú lateral. Cada módulo tiene su `renderXxxLink()` que solo
emite el enlace si el módulo está activo en config.
- **i18n**: `i18n` es un objeto compartido importado por todas las vistas.
`setLanguage(lang)` lo muta in-place (11 idiomas:
`en, es, fr, eu, de, it, pt, zh, ar, hi, ru` en
`src/client/assets/translations/`). El middleware llama `setLanguage(...)` en
cada petición. Las cadenas se referencian como `i18n.agendaTitle`, con
fallback `i18n.x || 'Default'`. **Nunca texto literal en las vistas.**
- **Temas**: `const theme = currentConfig.themes.current || "Dark-SNH"`
`<link href="/assets/themes/${theme}.css">`.
- **Helpers reutilizables** exportados por `main_views`: `userLink`,
`renderStateChip`, `renderVisibilityChip`, `renderSpreadButton`, `errorView`,
`formatCarbon`, `markdown(input, mentions)`.
---
## 2.6 Configuración
**`src/configs/config-manager.js`** — gestor central de `oasis-config.json`:
- Si el archivo no existe, escribe un `defaultConfig`.
- `getConfig()` lee y parsea el JSON **en cada llamada** (sin caché → cambios en
caliente).
- `saveConfig(newConfig)` reescribe el archivo.
Qué controla **`oasis-config.json`**:
- `themes.current` — tema CSS activo.
- `ux.current` — modo de interfaz (`"blocks"` o `"ainav"`).
- **`modules`** — ~50 flags `<nombre>Mod: "on"|"off"` que activan/desactivan cada
módulo. En backend se comprueba con `checkMod(ctx, mod)` (líneas 170-177): si
está `off`, redirige a `/modules`.
- `wallet`/`walletPub`, `ai.prompt`, `ssbLogStream.limit` (límite de mensajes
leídos del log), `homePage`, `language`, `wish`, `pmVisibility`,
`lanBroadcasting`.
**`src/configs/shared-state.js`** — estado en memoria (no persistente):
contador de inbox, huella de carbono, nº de peers online, valor eco, etc.
Lo actualiza el middleware periódico y lo consume `template`.
---
## 2.7 Tabla de módulos (modelo ↔ vista)
| Modelo | Vista | Notas |
|---|---|---|
| `main_models.js` | `main_views.js` | Núcleo: layout, i18n, temas, about/blob/friend |
| `activity_model` | `activity_view` | Feed de actividad |
| `agenda_model` | `agenda_view` | Agrega tasks/events/transfers/market/... |
| `audios_model` | `audio_view` | |
| `banking_model` | `banking_views` | ECOin, epochs |
| `blockchain_model` | `blockchain_view` | |
| `bookmarking_model` | `bookmark_view` | |
| `calendars_model` | `calendars_view` | Cifrado |
| `chats_model` | `chats_view` | Cifrado |
| `cipher_model` | `cipher_view` | |
| `courts_model` | `courts_view` | |
| `cv_model` | `cv_view` | |
| `documents_model` | `document_view` | |
| `events_model` | `event_view` | |
| `favorites_model` | `favorites_view` | Agrega favoritos |
| `feed_model` | `feed_view` | |
| `forum_model` | `forum_view` | Cifrado |
| **`games_model`** | **`games_view`** | Hall of fame + shell de juegos |
| `images_model` | `image_view` | |
| `inhabitants_model` | `inhabitants_view` | Perfiles |
| `jobs_model` | `jobs_view` | |
| `larp_model` | `larp_view` | |
| `legacy_model` | `legacy_view` | |
| `logs_model` | `logs_view` | |
| `maps_model` | `maps_view` | Cifrado |
| `market_model` | `market_view` | |
| `melody_model` | `melody_view` | |
| `opinions_model` | `opinions_view` | |
| `pads_model` | `pads_view` | Cifrado |
| `parliament_model` | `parliament_view` | |
| `pixelia_model` | `pixelia_view` | |
| `pm_model` | `pm_view` | Mensajería privada |
| `projects_model` | `projects_view` | |
| `reports_model` | `report_view` | |
| `search_model` | `search_view` | |
| `shops_model` | `shops_view` | |
| `stats_model` | `stats_view` | |
| `tags_model` | `tags_view` | |
| `tasks_model` | `task_view` | |
| `torrents_model` | `torrents_view` | |
| `transfers_model` | `transfer_view` | |
| `trending_model` | `trending_view` | |
| `tribes_model` + `tribes_content_model` | `tribes_view` | Cifrado |
| `videos_model` | `video_view` | |
| `votes_model` | `vote_view` | |
| `wallet_model` | `wallet_view` | |
| (sin modelo) | `AI_view` | Lógica en `src/AI/*` |
| `crypto.js`/`tribe_crypto.js` | — | Cifrado simétrico por dominio |
---
## 2.8 Mapa de dependencias entre archivos
**`backend.js` → modelos** (líneas 541-602): importa `main_models` y **todos**
los `*_model.js` como factories invocadas con `{ cooler, isPublic, ...deps }`.
Hay **inyección de dependencias entre modelos**: p.ej. `agenda_model` recibe
`{calendarsModel, eventsModel, tasksModel, marketModel, jobsModel,
projectsModel}`; `favorites_model` recibe casi todos los modelos de contenido.
**`backend.js` → vistas** (líneas ~1162-1216 + `require` inline): importa las
funciones de render de cada `*_view.js`. Algunas se cargan diferidas dentro de
los handlers (`require('../views/...')`).
**Modelos → importan**: `pull-stream`, `moment`, `./tombstone_validator`,
`../configs/config-manager`, helpers, y reciben `cooler` por parámetro (no
importan `gui` directamente). `tombstone_validator.js` no tiene dependencias
(función pura).
**Vistas → importan**: hyperaxe, `{template, i18n, userLink, ...}` de
`main_views`, `moment`, y a veces `../server/SSB_server` para obtener
`config.keys.id` (el feedId propio).
**Estado compartido**: `config-manager.js` (lee/escribe `oasis-config.json`) y
`shared-state.js` (memoria) son importados por backend, modelos y vistas; son
los puntos de coordinación transversal. `nameCache.js` (un Map en memoria) lo
alimenta `main_models` y lo consume `main_views`.
Siguiente: **[03-modulos-y-juegos.md](03-modulos-y-juegos.md)**.

View file

@ -0,0 +1,216 @@
# 3. El módulo de juegos y la anatomía de un módulo
> **Hallazgo clave que cambia las expectativas:** el módulo de juegos de Oasis
> **NO es un sistema multijugador asíncrono por SSB**. Es mucho más simple:
>
> - Cada juego es un **único `index.html` autocontenido** (HTML + CSS + JS
> inline), 100% client-side, servido como archivo estático y embebido en un
> `<iframe>`. No hay JS externos, ni sprites, ni carpetas de assets: cada
> carpeta de juego tiene **solo 2 archivos**: `index.html` + `thumbnail.svg`.
> - La **única integración con SSB** es publicar un mensaje tipo `gameScore`
> (puntuación individual) para un **Hall of Fame** (ranking global). No hay
> retos, ni partidas, ni movimientos sincronizados. El "multijugador" se
> reduce a comparar las puntuaciones de distintos autores leyendo el log SSB.
---
## 3.1 El modelo: `src/models/games_model.js` (67 líneas)
Minimalista. Solo gestiona puntuaciones. Patrón factory estándar:
`module.exports = ({ cooler }) => {...}`.
- **Líneas 5-9**: lista blanca `VALID_GAMES` (Set de ids permitidos). ⚠️ Incluye
14 juegos pero **NO** `rockpaperscissors` ni `audiopendulum` (que sí están en
la vista): esos dos **no pueden enviar score** (el modelo los rechaza).
- **Líneas 18-25** `readAll()`: vuelca todo el log con `createLogStream`.
- **Líneas 28-38** `submitScore(game, score)` — único método de escritura:
```js
async submitScore(game, score) {
if (!VALID_GAMES.has(game)) throw new Error('invalid game');
const n = Number(score);
if (!Number.isFinite(n) || n < 0 || n > 9999999) throw new Error('invalid score');
const ssbClient = await openSsb();
return new Promise((resolve, reject) => {
ssbClient.publish({ type: 'gameScore', game, score: Math.round(n) },
(err, msg) => err ? reject(err) : resolve(msg));
});
}
```
El mensaje SSB resultante: `{ type: 'gameScore', game: '<id>', score: <int> }`.
- **Líneas 40-65** `getHallOfFame()`: recorre el log, filtra
`content.type === 'gameScore'`, y por cada par `game:author` guarda **solo la
mejor puntuación**. Devuelve `hall[game] = [{author, score, game, ts}, ...]`
ordenado descendente y truncado a top-10 por juego.
---
## 3.2 La vista: `src/views/games_view.js` (151 líneas)
- **Líneas 5-22** `getGames()`: catálogo hardcodeado de **16 juegos**. Cada
entrada es `{ id, title: () => i18n.<key>, desc: () => i18n.<key> }`. Es el
registro maestro de la vista.
- **Línea 58** `VALID_GAME_IDS`: un **tercer** Set, usado para validar la ruta
del shell. Incluye los 16.
- **Líneas 26-56** `renderHallOfFame(hall)`: tabla por juego con thumbnail
(`/game-assets/${id}/thumbnail.svg`), título, descripción y filas (posición,
`userLink(author)`, score, fecha).
- **Líneas 60-93** `gameShellView(name)`: así se **embebe cada juego**. Valida
`name` contra `VALID_GAME_IDS` y renderiza un iframe:
```js
iframe({
src: `/game-assets/${name}/index.html`,
class: `game-iframe game-iframe-${name}`,
scrolling: 'no',
allowfullscreen: true
})
```
- **Líneas 95-151** `gamesView(filter, hall)`: el **lobby**. Barra de filtros
(`all` / `scoring`). En modo `all` lista cada juego con thumbnail, título,
récord top y botón PLAY → `/games/${id}`. En modo `scoring` invoca
`renderHallOfFame`.
> ⚠️ Hay **TRES listas** que deben mantenerse sincronizadas: `getGames()` y
> `VALID_GAME_IDS` (vista) + `VALID_GAMES` (modelo). La inconsistencia de
> `rockpaperscissors`/`audiopendulum` lo demuestra.
---
## 3.3 Estructura de un juego: `src/games/<nombre>/`
17 carpetas: `8ball, arkanoid, artillery, asteroids, audiopendulum, cocoland,
cocoman, ecoinflow, flipflop, labyrinth, neoninfiltrator, pingpong,
rockpaperscissors, spaceinvaders, tetris, tiktaktoe`.
**TODOS los juegos tienen exactamente 2 archivos**:
- `index.html` (6-10 KB): documento completo y autosuficiente, con `<style>` y
`<script>` inline. Sin dependencias externas, sin CDN.
- `thumbnail.svg` (~1-2 KB): miniatura SVG vectorial (viewBox ~`0 0 400 220`).
No existe distinción "arcade" vs "social SSB": todos son arcade HTML5/canvas o
DOM puro, client-side, en iframe. La única diferencia es si **envían score** o
no.
**Mecanismo de envío de score** (idéntico en todos los juegos puntuables;
ejemplo `tiktaktoe/index.html`):
```html
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="tiktaktoe">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
```
Claves del patrón:
- `target="_top"` → el POST **rompe el iframe** y navega la ventana superior
(necesario porque el juego corre sandboxeado dentro de `/games/<name>`).
- El JS del juego rellena `#scoreInput` al terminar
(`document.getElementById('scoreInput').value = score;`) y muestra el `div`.
- Cada juego tiene una topbar con `<a href="/games" target="_top">← Back</a>`.
El resto del `index.html` es lógica de juego propia en JS vanilla (tiktaktoe usa
minimax con poda alfa-beta; tetris usa canvas).
`gameScore` además es un tipo SSB de primera clase **indexado en búsqueda**
(`src/views/search_view.js` lo mapea a `/games/<game>` y lo renderiza con su
thumbnail).
---
## 3.4 Cómo se sirve y enruta
**Servido estático** — `src/client/middleware.js` (líneas 109-111):
```js
const gamesStatic = new Koa();
gamesStatic.use(koaStatic(join(__dirname, "..", "games")));
app.use(mount("/game-assets", gamesStatic));
```
Sub-app Koa con `koa-static` sobre `src/games/`, montada en `/game-assets`. Por
eso `/game-assets/tetris/index.html` sirve `src/games/tetris/index.html`.
**Independiente del router principal** → una carpeta nueva queda servida
automáticamente.
**Rutas dinámicas** — `src/backend/backend.js`:
- **Línea 598**: `const gamesModel = require('../models/games_model')({ cooler });`
- **Línea 1168**: `const { gamesView } = require("../views/games_view");`
- **Líneas 1358-1373**:
```js
.get('/games', async (ctx) => {
if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
const filter = ctx.query.filter === 'scoring' ? 'scoring' : 'all';
const hall = await gamesModel.getHallOfFame();
ctx.body = gamesView(filter, hall);
})
.get('/games/:name', async (ctx) => {
if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
const { gameShellView } = require('../views/games_view');
ctx.body = gameShellView(ctx.params.name);
})
.post('/games/submit-score', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
const { game, score } = ctx.request.body;
try { await gamesModel.submitScore(game, score); } catch (_) {}
ctx.redirect('/games?filter=scoring');
})
```
Flujo completo:
```
/games (lobby)
└─ PLAY → /games/:name (shell con iframe)
└─ iframe carga /game-assets/:name/index.html
└─ al terminar: POST /games/submit-score (target="_top")
└─ redirect /games?filter=scoring (Hall of Fame)
```
El guard `checkMod(ctx, 'gamesMod')` (líneas 170-177) revisa la config del
servidor + una cookie por-usuario; default `on`. Si está off → `/modules`.
---
## 3.5 Cómo se registra el módulo (puntos de integración)
| Lugar | Archivo | Qué hay |
|---|---|---|
| **Menú** | `src/views/main_views.js:898` | `renderGamesLink()` (emite el `navLink` si `gamesMod==="on"`); se invoca en línea 1280 dentro del `navGroup` "creative" |
| **Config** | `src/configs/oasis-config.json` | `"gamesMod": "on"` dentro de `modules` |
| **Página /modules** | `src/views/modules_view.js:23` | `{ name: 'games', label, description }` |
| **Listas de backend** | `src/backend/backend.js` | `'games'` en línea 1346 (GET /modules), 7054 (POST /save-modules), 7038 (`ALL_MODULES`) y presets (7040-7042) |
| **i18n** | `src/client/assets/translations/oasis_*.js` (×11) | `gamesTitle`, `gamesDescription`, `modulesGamesLabel/Description`, y por juego `games<Nombre>Title` + `games<Nombre>Desc` |
> El nombre del módulo está **hardcodeado y duplicado en muchos sitios**; no hay
> un registro central único. Por eso, al crear un módulo, cada punto es
> obligatorio. → [04-tutorial-modulo-desde-cero.md](04-tutorial-modulo-desde-cero.md)
---
## 3.6 Receta rápida: añadir un juego NUEVO al sistema existente
Para añadir un juego `foobar`:
1. **`src/games/foobar/index.html`** — HTML autocontenido (CSS+JS inline).
Incluir la topbar `← Back` y, si puntúa, el form oculto a
`/games/submit-score` con `target="_top"` y `name="game" value="foobar"`.
2. **`src/games/foobar/thumbnail.svg`** — miniatura.
3. **`src/views/games_view.js`**:
- Añadir a `getGames()`:
`{ id: 'foobar', title: () => i18n.gamesFoobarTitle, desc: () => i18n.gamesFoobarDesc }`.
- Añadir `'foobar'` a `VALID_GAME_IDS`.
4. **`src/models/games_model.js`** — añadir `'foobar'` a `VALID_GAMES`
(**obligatorio si puntúa**, o `submitScore` lo rechaza).
5. **i18n** — añadir `gamesFoobarTitle` y `gamesFoobarDesc` en TODOS los
`oasis_*.js`.
No hay que tocar el backend ni el static serving (la carpeta nueva ya se sirve
por `/game-assets/`).

View file

@ -0,0 +1,361 @@
# 4. Tutorial: crear un módulo desde cero
Vamos a crear un módulo nuevo, **paso a paso**, tomando `games` como plantilla.
Como caso práctico crearemos un módulo `arena` (un módulo de juegos propio: un
lobby de juegos con su propio ranking). Sustituye `arena` por el nombre de tu
módulo.
> **Idea clave**: en Oasis no existe un "registro central" de módulos. Crear un
> módulo = crear 2 archivos nuevos (model + view) y **tocar ~7 sitios** de
> integración. Esta guía los enumera todos. Si te saltas uno, el módulo
> funcionará a medias (p.ej. la ruta existe pero no aparece en el menú).
## Mapa de lo que vamos a tocar
```
NUEVO src/models/arena_model.js ← lógica de datos (SSB)
NUEVO src/views/arena_view.js ← render HTML (hyperaxe)
EDITA src/backend/backend.js ← instanciar modelo + rutas + 4 listas
EDITA src/views/main_views.js ← enlace en el menú lateral
EDITA src/configs/oasis-config.json ← flag arenaMod: "on"
EDITA src/views/modules_view.js ← entrada en la página /modules
EDITA src/client/assets/translations/oasis_*.js (×11) ← i18n
(opc) src/client/middleware.js ← static mount si sirves archivos (juegos)
```
---
## Paso 1 — El modelo: `src/models/arena_model.js`
El modelo encapsula **toda la lógica de datos**: publicar y leer mensajes SSB.
Sigue el patrón factory `({ cooler }) => ({...})`.
```js
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 5000;
const VALID_GAMES = new Set(['snake', 'breakout']); // tus juegos puntuables
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
function readAll(ssbClient) {
return new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, res) => (err ? reject(err) : resolve(res)))
);
});
}
return {
// ESCRITURA: publica un mensaje SSB tipado
async submitScore(game, score) {
if (!VALID_GAMES.has(game)) throw new Error('invalid game');
const n = Number(score);
if (!Number.isFinite(n) || n < 0 || n > 9999999) throw new Error('invalid score');
const ssbClient = await openSsb();
return new Promise((resolve, reject) => {
ssbClient.publish({ type: 'arenaScore', game, score: Math.round(n) },
(err, msg) => (err ? reject(err) : resolve(msg)));
});
},
// LECTURA: lee el log, filtra por type, agrega
async getRanking() {
const ssbClient = await openSsb();
const messages = await readAll(ssbClient);
const best = {};
for (const m of messages) {
const c = m.value && m.value.content;
if (!c || c.type !== 'arenaScore') continue;
if (!VALID_GAMES.has(c.game)) continue;
const key = `${c.game}:${m.value.author}`;
const score = Number(c.score);
if (!Number.isFinite(score)) continue;
if (!best[key] || score > best[key].score) {
best[key] = { author: m.value.author, score, game: c.game, ts: m.value.timestamp || 0 };
}
}
const ranking = {};
for (const g of VALID_GAMES) ranking[g] = [];
for (const e of Object.values(best)) if (ranking[e.game]) ranking[e.game].push(e);
for (const g of VALID_GAMES) ranking[g] = ranking[g].sort((a, b) => b.score - a.score).slice(0, 10);
return ranking;
}
};
};
```
Reglas del patrón de modelo:
- **Conexión SSB lazy** con `openSsb()` cacheado.
- **Escritura** siempre con `ssb.publish({ type: '<algo>', ... })`. El `type` es
el discriminador de tus mensajes en el log global compartido — elige un nombre
único (`arenaScore`, no `score`).
- **Lectura** con `createLogStream` + `pull.collect`, luego filtras por `type` en
memoria.
- **Borrado/edición** (si lo necesitas): publica
`{ type:'tombstone', target, author }` y usa
`src/models/tombstone_validator.js` al leer. Para editar, publica una versión
nueva con `replaces: <idAnterior>`.
---
## Paso 2 — La vista: `src/views/arena_view.js`
La vista recibe los datos del modelo y devuelve HTML con **hyperaxe**. Siempre se
envuelve en `template(...)` y se usan claves `i18n` (nunca texto literal).
```js
const { div, h2, p, section, form, input, button, a, img, table, tr, td, th, iframe }
= require("../server/node_modules/hyperaxe");
const { template, i18n, userLink } = require('./main_views');
const moment = require("../server/node_modules/moment");
const getGames = () => [
{ id: 'snake', title: () => i18n.arenaSnakeTitle, desc: () => i18n.arenaSnakeDesc },
{ id: 'breakout', title: () => i18n.arenaBreakoutTitle, desc: () => i18n.arenaBreakoutDesc },
];
const VALID_GAME_IDS = new Set(['snake', 'breakout']);
// Lobby
exports.arenaView = (filter = 'all', ranking = null) => {
const games = getGames();
const filterBar = div({ class: 'filter-group' },
form({ method: 'GET', action: '/arena' },
input({ type: 'hidden', name: 'filter', value: 'all' }),
button({ type: 'submit', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.arenaFilterAll)),
form({ method: 'GET', action: '/arena' },
input({ type: 'hidden', name: 'filter', value: 'scoring' }),
button({ type: 'submit', class: filter === 'scoring' ? 'filter-btn active' : 'filter-btn' }, i18n.arenaFilterScoring))
);
const content = filter === 'scoring' && ranking
? div({ class: 'arena-scoring' },
getGames().filter(g => ranking[g.id] && ranking[g.id].length).map(g =>
div({ class: 'arena-section' },
h2(g.title()),
table({ class: 'ranking-table' },
tr(th('#'), th(i18n.arenaPlayer), th(i18n.arenaScore)),
...ranking[g.id].map((e, i) =>
tr(td(String(i + 1)), td(userLink(e.author)), td(String(e.score))))))))
: div({ class: 'arena-list' },
games.map(g =>
div({ class: 'game-row' },
img({ src: `/arena-assets/${g.id}/thumbnail.svg`, loading: 'lazy' }),
div(h2(g.title()), p(g.desc())),
a({ href: `/arena/${g.id}`, class: 'filter-btn' }, i18n.arenaPlayButton))));
return template(i18n.arenaTitle, section(h2(i18n.arenaTitle), filterBar), section(content));
};
// Shell: embebe el juego en un iframe
exports.arenaShellView = (name) => {
if (!VALID_GAME_IDS.has(name)) return template(i18n.arenaTitle, section(p(i18n.notFound || 'Not found')));
const game = getGames().find(g => g.id === name);
return template(game ? game.title() : name,
section({ class: 'game-shell-section' },
iframe({ src: `/arena-assets/${name}/index.html`, class: `game-iframe game-iframe-${name}`,
scrolling: 'no', allowfullscreen: true })));
};
```
Reglas del patrón de vista:
- `template(titulo, ...secciones)` siempre como envoltorio (te da head, menú
lateral, tema, etc.).
- Helpers de `main_views`: `userLink(feedId)`, `markdown(texto, mentions)`,
chips, `errorView`...
- Si tu módulo no es de juegos, simplemente no uses iframe/static: renderiza tus
datos directamente (listas, formularios POST a tus rutas).
---
## Paso 3 — Backend: `src/backend/backend.js`
**3a. Instanciar el modelo** (junto a la línea ~598, donde están los demás):
```js
const arenaModel = require('../models/arena_model')({ cooler });
```
**3b. Importar la vista** (junto a la línea ~1168):
```js
const { arenaView } = require("../views/arena_view");
```
**3c. Añadir las rutas** (dentro de la cadena `router....`, junto a las de
games en ~1358). Cada ruta empieza con el guard `checkMod`:
```js
.get('/arena', async (ctx) => {
if (!checkMod(ctx, 'arenaMod')) { ctx.redirect('/modules'); return; }
const filter = ctx.query.filter === 'scoring' ? 'scoring' : 'all';
const ranking = await arenaModel.getRanking();
ctx.body = arenaView(filter, ranking);
})
.get('/arena/:name', async (ctx) => {
if (!checkMod(ctx, 'arenaMod')) { ctx.redirect('/modules'); return; }
const { arenaShellView } = require('../views/arena_view');
ctx.body = arenaShellView(ctx.params.name);
})
.post('/arena/submit-score', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'arenaMod')) { ctx.redirect('/modules'); return; }
const { game, score } = ctx.request.body;
try { await arenaModel.submitScore(game, score); } catch (_) {}
ctx.redirect('/arena?filter=scoring');
})
```
**3d. Añadir `'arena'` a las cuatro listas de módulos** (si no lo haces, el
módulo no aparecerá en la página `/modules` ni se podrá activar/desactivar):
- Línea **1346** — array `modules` del GET `/modules`.
- Línea **7054** — array `modules` del POST `/save-modules`.
- Línea **7038**`ALL_MODULES`.
- Líneas **7040-7042** — los presets (`minimal`/`social`/`economy`/`full`) donde
quieras que aparezca por defecto.
---
## Paso 4 — Menú lateral: `src/views/main_views.js`
**4a. Crear `renderArenaLink()`** (copia el patrón de `renderGamesLink`,
línea ~898):
```js
const renderArenaLink = () => {
const arenaMod = getConfig().modules.arenaMod === "on";
return arenaMod
? [navLink({ href: "/arena", emoji: "🎮", text: i18n.arenaTitle, class: "arena-link enabled" })]
: "";
};
```
**4b. Invocarlo dentro de un `navGroup`** (junto a `renderGamesLink()` en la
línea ~1280, grupo "creative", o el grupo que prefieras):
```js
renderGamesLink(),
renderArenaLink(), // ← añadir
```
---
## Paso 5 — Config: `src/configs/oasis-config.json`
Añadir el flag dentro de `modules` (junto a `"gamesMod": "on"`):
```json
"arenaMod": "on",
```
`config-manager.js` también tiene un `defaultConfig`; si quieres que el módulo
exista en instalaciones nuevas, añade el flag ahí también.
---
## Paso 6 — Página `/modules`: `src/views/modules_view.js`
Añadir una entrada al array `modules` (línea ~23). El toggle on/off lo renderiza
el código genérico (`${name}Mod` / `${name}Form`):
```js
{ name: 'arena', label: i18n.modulesArenaLabel, description: i18n.modulesArenaDescription },
```
---
## Paso 7 — i18n: `src/client/assets/translations/oasis_*.js`
En **todos** los archivos de idioma (×11) añade, como mínimo:
```js
arenaTitle: 'Arena',
arenaDescription: 'Play and compete in your network.',
arenaFilterAll: 'All',
arenaFilterScoring: 'Ranking',
arenaPlayButton: 'PLAY',
arenaPlayer: 'Player',
arenaScore: 'Score',
modulesArenaLabel: 'Arena',
modulesArenaDescription: 'A games arena with rankings.',
// y por cada juego:
arenaSnakeTitle: 'Snake', arenaSnakeDesc: 'Classic snake.',
arenaBreakoutTitle: 'Breakout', arenaBreakoutDesc: 'Break the bricks.',
```
Empieza por `oasis_en.js` (es el fallback). Si una clave falta en un idioma,
usa el patrón `i18n.x || 'Default'` en la vista para evitar `undefined`.
---
## Paso 8 (opcional) — Servir archivos estáticos (juegos)
Solo si tu módulo sirve archivos (como hace games con sus `index.html`). En
`src/client/middleware.js`, replica el bloque de games (líneas 109-111):
```js
const arenaStatic = new Koa();
arenaStatic.use(koaStatic(join(__dirname, "..", "arena-games")));
app.use(mount("/arena-assets", arenaStatic));
```
Y crea tus juegos en `src/arena-games/<id>/index.html` + `thumbnail.svg`. Cada
`index.html` autocontenido; si puntúa, el form oculto apunta a
`/arena/submit-score` con `target="_top"`:
```html
<form method="POST" action="/arena/submit-score" target="_top">
<input type="hidden" name="game" value="snake">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score</button>
</form>
```
---
## Checklist final
```
[ ] src/models/arena_model.js (factory {cooler}, publish/read SSB)
[ ] src/views/arena_view.js (hyperaxe + template + i18n)
[ ] backend.js: instancia modelo (~598)
[ ] backend.js: import vista (~1168)
[ ] backend.js: rutas GET/POST con checkMod(ctx,'arenaMod')
[ ] backend.js: 'arena' en las 4 listas (1346, 7054, 7038, presets)
[ ] main_views.js: renderArenaLink() + llamada en navGroup
[ ] oasis-config.json: "arenaMod": "on"
[ ] modules_view.js: entrada en array modules
[ ] oasis_*.js (×11): claves i18n
[ ] (opc) middleware.js: static mount + carpeta de juegos
```
## Cómo probarlo
```bash
# Arranca sbot + frontend
cd src/server && npm run start
# o por separado (sbot ya levantado):
node ../backend/backend.js
```
Abre `http://localhost:3000/arena`. Si redirige a `/modules`, activa el módulo
ahí (o revisa `arenaMod` en `oasis-config.json`). Comprueba:
1. El enlace aparece en el menú lateral.
2. `/arena` muestra el lobby.
3. `/arena/snake` embebe el juego.
4. Al enviar score, redirige a `/arena?filter=scoring` y aparece en el ranking.
> Errores típicos: `submitScore` lanza `invalid game` → falta el id en
> `VALID_GAMES` (modelo). El juego no carga en el iframe → ruta de
> `/arena-assets/...` mal o falta el static mount. La vista peta con `undefined`
> → falta una clave i18n; añade el fallback `|| '...'`.
Siguiente: **[05-mapa-funciones.txt](05-mapa-funciones.txt)** — el mapa ASCII de
relaciones entre funciones, persistencia y media.

View file

@ -0,0 +1,262 @@
================================================================================
OASIS · MAPA DE FUNCIONES Y FLUJO
Cómo se relacionan las funciones lógicas entre archivos,
la persistencia (SSB) y la media (blobs)
versión 0.8.1
================================================================================
LEYENDA
[proc] proceso ( ) función / método
--> llamada / flujo === límite de proceso ~~~ red / disco
*.js archivo #NNN número de línea aprox.
================================================================================
0. VISIÓN GLOBAL: DOS PROCESOS
================================================================================
NAVEGADOR OTROS PEERS SSB
(HTML puro, (misma SHS cap)
sin JS) |
| HTTP :3000 | net:IP:8008~shs
v v ~~~~~~~~~~~~~
============================[proc gui]== ==============[proc sbot]======
| src/backend/backend.js (Koa router) | | src/server/SSB_server.js |
| | | SecretStack({caps}).use(..)|
| cooler = src/client/gui.js | | ssb-db (log append-only) |
| | | <-> | ssb-ebt / ssb-friends |
| | cooler.open() |unix | ssb-blobs / ssb-conn |
| v |sock | ssb-box / ssb-private |
| ssb (cliente muxrpc) ---------------|~noauth-> muxrpc API (publish,get, |
| | | createLogStream,...) |
========================================= ===============================
modelos + vistas replicación P2P por gossip
================================================================================
1. CADENA DE UNA PETICIÓN (el patrón de TODO módulo)
================================================================================
navegador
| GET /modulo (o POST /modulo/accion)
v
middleware.js (#43..#209) ── app.listen({host,port})
| setLanguage(cookie) · gate indexación · refresco shared-state(60s)
v
backend.js router.get('/modulo') #1270..#7124 (~472 rutas)
| checkMod(ctx,'moduloMod') #170 ── off? --> redirect /modules
|
+--> xModel.metodo(filtro) [ LECTURA / ESCRITURA SSB ] -----+
| (src/models/xxx_model.js) |
| v
| <------------------ datos JS ------------------------ cooler.open()
| (gui.js)
+--> xView(datos, filtro) [ RENDER HTML ] |
| (src/views/xxx_view.js) v
| template(i18n.titulo, section(...)) ssb muxrpc
v
ctx.body = "<html>...</html>" --> Koa --> navegador
POST: koaBody() -> stripDangerousTags() -> xModel.crear() -> ctx.redirect()
(patrón Post/Redirect/Get; el GET siguiente re-renderiza)
================================================================================
2. PERSISTENCIA: cómo el MODELO habla con SSB (pull-stream + publish)
================================================================================
src/models/xxx_model.js = module.exports = ({cooler, ...deps}) => ({...})
openSsb() ............ if(!ssb) ssb = await cooler.open(); // lazy, cacheado
|
v
ESCRIBIR (persistir):
ssb.publish({ type:'<tipo>', ...campos }, cb)
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+--> ssb-db: añade mensaje FIRMADO (ed25519) al log append-only
| del feed propio @miclave.ed25519
+--> ssb-ebt replica el mensaje a los peers (hops<=2) ~~~~>
LEER (consultar):
pull(
ssb.createLogStream({limit}) <-- lee el log local (todos los feeds
pull.filter(m => m.value.content.type === '<tipo>') replicados)
pull.collect((err,res)=>...)
)
(variantes: ssb.messagesByType, ssb.query.read, ssb.backlinks.read,
ssb.createUserStream{id})
BORRAR (lógico; el log NO se borra):
ssb.publish({ type:'tombstone', target:<msgId>, author })
|
+--> tombstone_validator.js :: buildValidatedTombstoneSet(msgs)
acepta SOLO si author(tombstone) === author(target) [autoría]
EDITAR:
ssb.publish({ ...nuevo, replaces:<idAnterior> }) + tombstone del viejo
|
+--> al leer, se sigue la cadena replaces -> hasta la "punta"
----------------------------------------------------------------------------
TIPOS DE MENSAJE (content.type) = el discriminador en el log compartido
----------------------------------------------------------------------------
nativos SSB : post · vote(like) · contact(follow/block) · about(perfil)
Oasis : votes · tombstone · task · event · calendar · transfer ·
tribe · market · report · job · project · gameScore · ...
================================================================================
3. MEDIA: cómo viajan los ficheros (BLOBS, fuera del log)
================================================================================
El log solo guarda el HASH. El binario viaja aparte por la red SSB.
SUBIR:
navegador --(multipart)--> backend.js --> blobHandler.js
ssb.blobs.add(pull-stream) --> &<sha256>.sha256 (ref = hash)
(luego se publica un mensaje con ese ref dentro de content)
MOSTRAR / DESCARGAR:
vista genera <img src="/blob/&xxx.sha256"> ó markdown.js resuelve menciones
|
v
backend.js ruta /blob/:id --> blobHandler.js
ssb.blobs.has(id)? --- no ---> ssb.blobs.want(id) ~~~~~~~~~~~~~~~~~>
| (gossip "want/has" a los peers)
+-- si ---> ssb.blobs.get(id) -> pull-stream -> ctx.body (bytes)
límite: 50 MB (ssb_config.js #17-19)
================================================================================
4. RENDER: cómo la VISTA construye HTML (hyperaxe, sin .html)
================================================================================
src/views/xxx_view.js
require("../server/node_modules/hyperaxe") -> div(), form(), section()...
require('./main_views') -> { template, i18n, userLink, markdown, chips }
exports.xView(datos) =
template( i18n.xTitle, // <- LAYOUT global
section( h2(i18n.xTitle), ...lista... ) )
|
v
main_views.js :: template(titulo, ...elems) #1017..#1321
html
+- head: <title> · CSS · /assets/themes/<tema>.css (oasis-config.themes)
+- body
+- header (logo, inbox badge <- shared-state, publish, search)
+- sidebar-left = navGroup(...) { renderXxxLink() por módulo }
| navLink({href,emoji,text}) #327
| renderGamesLink() #898 -> si gamesMod==='on' (config-manager)
+- main-content = tus secciones
i18n: objeto compartido mutado por setLanguage(lang) (11 idiomas)
backend middleware lo fija por cookie en cada request
markdown.js: ssb-markdown -> resuelve @feed,%msg,&blob,#hashtag a rutas /...
================================================================================
5. GRAFO DE DEPENDENCIAS ENTRE ARCHIVOS
================================================================================
src/backend/backend.js (orquestador central)
|--require--> ../client/gui.js .................. cooler (conexión SSB)
|--require--> ../client/middleware.js ........... http server Koa
|--require--> ../configs/config-manager.js ...... getConfig/saveConfig
|--require--> ../configs/shared-state.js ........ estado en memoria
|--require--> ./sanitizeHtml.js / ./blobHandler.js
|--require--> ../server/SSB_server.js ........... config + (sbot in-process)
|--require--> ../models/*_model.js (factories: ({cooler,deps}))
| |--inyecta--> otros modelos (agenda<-tasks,events,market..)
| |--inyecta--> *Crypto (crypto.js por dominio)
|--require--> ../views/*_view.js (funciones de render)
src/models/xxx_model.js
|--require--> pull-stream · moment
|--require--> ./tombstone_validator.js (función pura, sin deps)
|--require--> ../configs/config-manager.js
|--recibe----> cooler (NO importa gui directamente)
src/views/xxx_view.js
|--require--> hyperaxe
|--require--> ./main_views.js { template, i18n, userLink, helpers }
|--require--> moment · (a veces) ../server/SSB_server (feedId propio)
src/views/main_views.js (el "framework" de vistas)
|--require--> hyperaxe · lodash · highlight.js · qrcode · moment
|--require--> ./markdown.js · ../backend/renderUrl.js · ./sanitizeHtml?
|--require--> ../backend/nameCache.js (Map nombres <- main_models warmup)
|--require--> ../client/gui.js · ../configs/{config-manager,shared-state}
|--require--> ../client/assets/translations/i18n.js (11 idiomas)
src/server/SSB_server.js (sbot)
|--require--> secret-stack · ssb-caps · ssb-db · plugins ssb-*
|--require--> ./ssb_config.js (<- server-config.json) · ./ssb_metadata.js
|--require--> ./lanRouter.js (descubrimiento LAN)
================================================================================
6. EL MÓDULO GAMES, DE PRINCIPIO A FIN (ejemplo concreto)
================================================================================
MENU main_views.js #898 renderGamesLink() --(gamesMod==on)--> /games
|
LOBBY backend.js #1358 GET /games |
checkMod #170 -> gamesModel.getHallOfFame() #40 ----------+
| pull(createLogStream) |
| filter type==='gameScore' |
| best[game:author]=max(score) |
gamesView(filter,hall) #95 -> HTML |
|
PLAY GET /games/:name #1364 -> gameShellView(name) #60 |
<iframe src="/game-assets/<name>/index.html"> |
| |
STATIC middleware.js #109 mount('/game-assets', src/games/) ~~disk~+
|
JUEGO src/games/<name>/index.html (HTML+CSS+JS inline, canvas)
al terminar: form POST -> /games/submit-score target=_top
|
SCORE POST /games/submit-score #1369
gamesModel.submitScore(game,score) #28
ssb.publish({type:'gameScore',game,score}) ~~> SSB log
redirect /games?filter=scoring -> Hall of Fame
LISTAS A SINCRONIZAR (sin registro central):
games_view.js getGames() #5 + VALID_GAME_IDS #58 (16 juegos)
games_model.js VALID_GAMES #5 (14 -> falta rps,audio)
backend.js listas módulos #1346 #7038 #7054 #7040 ('games')
oasis-config.json modules.gamesMod
modules_view.js array modules #23
oasis_*.js gamesTitle, games<Nombre>Title/Desc (x11 idiomas)
================================================================================
7. CONFIG Y ESTADO (puntos de coordinación transversal)
================================================================================
oasis-config.json <--getConfig()/saveConfig()--> config-manager.js
| modules.{xMod:on/off} themes.current ux.current language
| wish pmVisibility lanBroadcasting ssbLogStream.limit wallet
+--leen--> backend.js (checkMod) · main_views (render links/tema) · modelos
server-config.json <-- ssb_config.js --> SSB_server.js
| caps.shs (red privada) friends.hops=2 connections (puerto 8008, unix)
shared-state.js (memoria, NO persistente)
| inboxCount carbon peersOnline ecoValue lastRefresh
+--escribe--> middleware refresco 60s +--lee--> main_views (badges)
nameCache.js (Map en memoria)
+--alimenta--> main_models._startNameWarmup (escucha 'about' en live)
+--consume---> main_models.about · main_views (mostrar nombres)
================================================================================
RESUMEN EN UNA FRASE
================================================================================
backend.js enruta -> el MODELO persiste/lee mensajes tipados en el log SSB
(replicado P2P por hops) y los blobs por want/has -> la VISTA (hyperaxe)
los pinta dentro de template() con i18n y tema -> y todo módulo se activa con
un flag xMod en oasis-config.json comprobado por checkMod().
================================================================================