# 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: '', ... })`. 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: `. --- ## 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//index.html` + `thumbnail.svg`. Cada `index.html` autocontenido; si puntúa, el form oculto apunta a `/arena/submit-score` con `target="_top"`: ```html
``` --- ## 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.