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

13 KiB
Raw Blame History

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 }) => ({...}).

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

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):

const arenaModel = require('../models/arena_model')({ cooler });

3b. Importar la vista (junto a la línea ~1168):

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:

.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 7038ALL_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):

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):

renderGamesLink(),
renderArenaLink(),   // ← añadir

Paso 5 — Config: src/configs/oasis-config.json

Añadir el flag dentro de modules (junto a "gamesMod": "on"):

"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):

{ 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:

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):

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":

<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

# 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 — el mapa ASCII de relaciones entre funciones, persistencia y media.