OASIS_LINUX/docs/devs/architecture/03-modulos-y-juegos.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

216 lines
9.1 KiB
Markdown
Raw Permalink 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.

# 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/`).