OASIS_LINUX/docs/devs/architecture/04-tutorial-modulo-desde-cero.md
SITO ddd2787b73 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.
2026-08-19 00:35:08 +02:00

361 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.