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.
361 lines
13 KiB
Markdown
361 lines
13 KiB
Markdown
# 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.
|