diff --git a/CLAUDE.md b/CLAUDE.md index 4b7f688..8e799b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,8 @@ El contenido del juego (niveles, entidades, objetos) se deriva de la wiki backro ## Comandos -No hay tests ni linter. Los scripts del pipeline usan solo la stdlib de Node (requiere Node 18+ por `fetch` global). +No hay linter ni build del cliente. Los scripts del pipeline y los tests unitarios +usan solo la stdlib de Node (requiere Node 18+ por `fetch` global). ``` node pipeline/download.js # Fase 0: descarga la wiki → data/raw/.json (re-ejecutable, salta lo ya descargado) @@ -21,6 +22,8 @@ node pipeline/select-pilot.js # Fase 2a: elige los ~30 niveles del piloto (BFS node pipeline/make-map.js # Fase 2b: regenera data/game/mapa-piloto.html (diagrama SVG del grafo) desde levels.es.json node pipeline/build-data.js # empaqueta data/game/*.es.json → game/js/data.js ← RE-EJECUTAR tras editar cualquier ficha node pipeline/build-assets-manifest.js # inventaría game/assets/ → game/js/assets-manifest.js ← RE-EJECUTAR tras añadir/quitar sprites/sonidos/iconos (el juego solo carga lo inventariado; sin sondeos ni 404) +node pipeline/test-unit.js # todos los tests unitarios, sin procesos hijo +node server/test-riesgo-void.js # ramas éxito/muerte del Vacío con d20 forzado, sin azar ni servidor externo ``` Para jugar (modo PRINCIPAL, online): `node server/server.js` → http://localhost:8080 (WebSocket `/ws`; `MMO_DEV=1` habilita `?nivel=`, `MMO_ADMIN` fija la clave de guardián). Ver la entrada v21-v22 más abajo. @@ -43,14 +46,54 @@ wiki fandom → data/raw/ (crudo, 1100+ archivos, NO editar) → data/parsed/ (g Sin módulos ES: cada archivo de `game/js/` es un IIFE que expone un global en `window` (`RNG`, `MapGen`, `GAME_DATA`...). **El orden de los ` + + + + + + + + @@ -432,22 +441,25 @@

Tu recorrido

+ + - - - - - - - - - - + + + + + + + + @@ -460,6 +472,8 @@

Tu recorrido

+ + diff --git a/game/js/assets-manifest.js b/game/js/assets-manifest.js index 271b10a..e04232a 100644 --- a/game/js/assets-manifest.js +++ b/game/js/assets-manifest.js @@ -29,13 +29,16 @@ window.ASSETS_MANIFEST = { }, "sonidos": { "smiler": "assets/sounds/entidades/smiler.wav", - "dado": "assets/sounds/dado.wav", - "dano": "assets/sounds/dano.mp3", - "golpe": "assets/sounds/golpe.mp3", - "latido": "assets/sounds/latido.wav", - "muerte": "assets/sounds/muerte.mp3", - "paso": "assets/sounds/paso.mp3", - "registrar": "assets/sounds/registrar.wav" + "dado": "assets/sounds/dado.ogg", + "dano": "assets/sounds/dano.ogg", + "golpe": "assets/sounds/golpe.ogg", + "latido": "assets/sounds/latido.ogg", + "muerte": "assets/sounds/muerte.ogg", + "paso": "assets/sounds/paso.ogg", + "registrar": "assets/sounds/registrar.ogg", + "susurros-lvl-0-1": "assets/sounds/susurros-lvl-0-1.ogg", + "susurros-lvl-0-2": "assets/sounds/susurros-lvl-0-2.ogg", + "susurros-lvl-0-3": "assets/sounds/susurros-lvl-0-3.ogg" }, "ambientes": { "level-0": "assets/sounds/niveles/level-0.mp3", @@ -49,5 +52,15 @@ window.ASSETS_MANIFEST = { "level-6": "assets/sounds/niveles/level-6.mp3", "level-777": "assets/sounds/niveles/level-777.mp3", "the-hub": "assets/sounds/niveles/the-hub.mp3" + }, + "texturasNiveles": { + "level-0": { + "pared": "assets/levels/level-0/textures/pared.png", + "suelos": [ + "assets/levels/level-0/textures/suelo-1.png", + "assets/levels/level-0/textures/suelo-2.png", + "assets/levels/level-0/textures/suelo-3.png" + ] + } } }; diff --git a/game/js/config/identity.js b/game/js/config/identity.js new file mode 100644 index 0000000..f65a035 --- /dev/null +++ b/game/js/config/identity.js @@ -0,0 +1,27 @@ +// Identidad persistente del cliente MMO. Cliente remoto y servidor local usan +// exactamente el mismo token y no conocen su almacenamiento. +(function () { + 'use strict'; + + const TOKEN_KEY = 'mmo-token'; + + function crearToken() { + const bytes = window.crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + } + + function token() { + try { + let value = window.localStorage.getItem(TOKEN_KEY); + if (!value) { + value = crearToken(); + window.localStorage.setItem(TOKEN_KEY, value); + } + return value; + } catch (e) { + return 'sin-token'; + } + } + + window.ClientIdentity = { token }; +})(); diff --git a/game/js/config/identity.test.js b/game/js/config/identity.test.js new file mode 100644 index 0000000..2f7d07c --- /dev/null +++ b/game/js/config/identity.test.js @@ -0,0 +1,35 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, 'identity.js'), 'utf8'); + +function cargar(storage) { + const window = { + localStorage: storage, + crypto: { getRandomValues: (bytes) => { bytes.fill(10); return bytes; } }, + }; + vm.runInNewContext(source, { window, Uint8Array, Array }); + return window.ClientIdentity; +} + +test('crea una sola identidad y la reutiliza', () => { + const values = new Map(); + const identity = cargar({ + getItem: (key) => values.get(key) || null, + setItem: (key, value) => values.set(key, value), + }); + const first = identity.token(); + assert.equal(first.length, 32); + assert.equal(identity.token(), first); + assert.equal(values.get('mmo-token'), first); +}); + +test('devuelve una identidad segura si el almacenamiento falla', () => { + const identity = cargar({ getItem: () => { throw new Error('bloqueado'); } }); + assert.equal(identity.token(), 'sin-token'); +}); diff --git a/game/js/config/options.js b/game/js/config/options.js new file mode 100644 index 0000000..01e43bb --- /dev/null +++ b/game/js/config/options.js @@ -0,0 +1,80 @@ +// Configuración persistente del cliente. Este módulo es la única pieza que +// conoce la clave de localStorage y los valores por defecto. +(function () { + 'use strict'; + + const STORAGE_KEY = 'backrooms-opts'; + const GAMEPAD_DEFAULTS = Object.freeze({ + interact: 0, + wait: 2, + light: 3, + handL: 4, + handR: 5, + backpack: 1, + menu: 9, + map: 6, + log: 7, + codex: 8, + journal: 11, + chat: 12, + }); + + function esObjeto(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); + } + + function valoresIniciales() { + const tactil = !!window.matchMedia?.('(pointer: coarse)').matches; + return { + gamepadMap: { ...GAMEPAD_DEFAULTS }, + cursorSpeed: 8, + dado: true, + mostrarFps: false, + camaraModo: 'libre', + camaraInvertir: tactil, + camaraSens: 100, + camaraSeguimiento: 8, + resolucion: 'auto16x9', + fpsMax: 'vsync', + menuMusica: 'menu1', + }; + } + + function cargar() { + const defaults = valoresIniciales(); + try { + const stored = JSON.parse(window.localStorage.getItem(STORAGE_KEY)); + if (!esObjeto(stored)) return defaults; + return { + ...defaults, + ...stored, + gamepadMap: { + ...GAMEPAD_DEFAULTS, + ...(esObjeto(stored.gamepadMap) ? stored.gamepadMap : {}), + }, + }; + } catch (e) { + return defaults; + } + } + + const valores = cargar(); + + function guardar() { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(valores)); + return true; + } catch (e) { + return false; + } + } + + function restaurarMando() { + valores.gamepadMap = { ...GAMEPAD_DEFAULTS }; + valores.cursorSpeed = 8; + guardar(); + } + + window.OPTS = valores; // compatibilidad con los módulos existentes + window.Options = { valores, guardar, restaurarMando }; +})(); diff --git a/game/js/config/options.test.js b/game/js/config/options.test.js new file mode 100644 index 0000000..d777adc --- /dev/null +++ b/game/js/config/options.test.js @@ -0,0 +1,55 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, 'options.js'), 'utf8'); + +function cargarOptions(saved, tactil = false) { + const writes = []; + const localStorage = { + getItem() { return saved; }, + setItem(key, value) { writes.push([key, value]); }, + }; + const window = { + localStorage, + matchMedia: () => ({ matches: tactil }), + }; + vm.runInNewContext(source, { window, Object, Array, JSON }); + return { api: window.Options, opts: window.OPTS, writes }; +} + +test('combina opciones guardadas sin perder controles nuevos', () => { + const { opts } = cargarOptions(JSON.stringify({ + dado: false, + gamepadMap: { interact: 7 }, + })); + + assert.equal(opts.dado, false); + assert.equal(opts.gamepadMap.interact, 7); + assert.equal(opts.gamepadMap.backpack, 1); +}); + +test('usa valores seguros si el almacenamiento está corrupto', () => { + const { opts } = cargarOptions('{mal', true); + assert.equal(opts.dado, true); + assert.equal(opts.camaraInvertir, true); + assert.equal(opts.resolucion, 'auto16x9'); +}); + +test('restaura y persiste la configuración del mando', () => { + const { api, opts, writes } = cargarOptions(JSON.stringify({ + cursorSpeed: 20, + gamepadMap: { interact: 9 }, + })); + + api.restaurarMando(); + + assert.equal(opts.cursorSpeed, 8); + assert.equal(opts.gamepadMap.interact, 0); + assert.equal(writes.length, 1); + assert.equal(writes[0][0], 'backrooms-opts'); +}); diff --git a/game/js/debug/selftest.js b/game/js/debug/selftest.js new file mode 100644 index 0000000..0da1927 --- /dev/null +++ b/game/js/debug/selftest.js @@ -0,0 +1,296 @@ +// Autopruebas ejecutables por URL. Se mantienen fuera del arranque normal para +// que main.js solo decida cuándo activarlas. +(function () { + 'use strict'; + + function capturarErrores() { + const errores = []; + window.onerror = (msg, src, line) => { + errores.push(`${msg} @${(src || '').split('/').pop()}:${line}`); + }; + return errores; + } + + function publicar(data, errores) { + const div = document.createElement('div'); + div.id = 'selftest-result'; + div.textContent = JSON.stringify(data); + document.body.appendChild(div); + document.title = errores.length ? 'SELFTEST-ERRORES' : 'SELFTEST-OK'; + } + + function iniciarOnline({ params, world, input }) { + const errores = capturarErrores(); + const total = parseInt(params.get('selftest'), 10) || 300; + let ticks = 0; + const visitados = new Set(); + let rumbo = null; + let huyeHasta = -1; + let huidaDir = null; + let ultimaPos = ''; + let quieto = 0; + + const interval = setInterval(() => { + try { + if (!window.Net.activo || !world.level || !world.map) return; + visitados.add(world.level.id); + if (ticks >= total) { + clearInterval(interval); + input.reset('automation'); + window.Net.parar(); + publicar({ + ticks, + nivel: world.level?.id, + visitados: [...visitados], + posicion: [world.player?.x, world.player?.y], + mapa: world.map ? [world.map.grid.w, world.map.grid.h] : null, + salud: world.player?.salud, + sed: world.player?.sed, + cordura: world.player?.cordura, + inv: world.player?.inv, + entidadesVivas: world.entities.filter((entity) => entity.viva).length, + errores, + erroresRender: window.__renderErrors || [], + local: window.MODO_LOCAL && window.Local?.jugador ? { + sed: window.Local.jugador.sed, + salud: window.Local.jugador.salud, + cordura: window.Local.jugador.cordura, + posSala: [ + Math.round(window.Local.jugador.x * 10) / 10, + Math.round(window.Local.jugador.y * 10) / 10, + ], + caminado: Math.round(window.Local.jugador._sedAcum || 0), + rechazos: window.Local.jugador.rechazos, + stats: window.Local.stats, + } : null, + }, errores); + return; + } + ticks++; + const card = document.getElementById('screen-card'); + if (card.style.display !== 'none') { document.getElementById('btn-enter').click(); return; } + const choice = document.getElementById('choice-modal'); + if (choice?.style.display !== 'none') { + const buttons = document.querySelectorAll('#choice-btns button'); + if (buttons.length) { + const cross = Math.random() < 0.7; + (cross ? buttons[0] : buttons[buttons.length - 1]).click(); + if (!cross) huyeHasta = ticks + 25; + } + return; + } + const positionKey = `${Math.round(world.player.x * 4)},${Math.round(world.player.y * 4)}`; + if (positionKey === ultimaPos) { + if (++quieto > 20 && huyeHasta < ticks) { huyeHasta = ticks + 15; quieto = 0; } + } else { + quieto = 0; + ultimaPos = positionKey; + } + const grid = world.map.grid; + const playerX = Math.round(world.player.x); + const playerY = Math.round(world.player.y); + if ((!rumbo || rumbo.nivel !== world.level.id) && world.map.exits.length) { + let best = null; + let bestDistance = Infinity; + for (const exit of world.map.exits) { + const distances = window.MapGen.bfsDist(grid, exit.x, exit.y); + const distance = distances[playerY * grid.w + playerX]; + if (distance >= 0 && distance < bestDistance) { + bestDistance = distance; + best = distances; + } + } + if (best) rumbo = { nivel: world.level.id, dist: best }; + } + let dx = 0; + let dy = 0; + if (ticks < huyeHasta) { + if (!huidaDir || Math.random() < 0.1) { + const angle = Math.random() * Math.PI * 2; + huidaDir = [Math.cos(angle), Math.sin(angle)]; + } + [dx, dy] = huidaDir; + } else if (rumbo?.nivel === world.level.id && Math.random() < 0.85) { + const current = rumbo.dist[playerY * grid.w + playerX]; + for (const [moveX, moveY] of [[0, -1], [0, 1], [-1, 0], [1, 0]]) { + const x = playerX + moveX; + const y = playerY + moveY; + if (x < 0 || y < 0 || x >= grid.w || y >= grid.h) continue; + const value = rumbo.dist[y * grid.w + x]; + if (value >= 0 && (current < 0 || value < current)) { dx = moveX; dy = moveY; break; } + } + } + if (!dx && !dy) { + const angle = Math.random() * Math.PI * 2; + dx = Math.cos(angle); + dy = Math.sin(angle); + } + const magnitude = Math.hypot(dx, dy) || 1; + input.set('automation', dx / magnitude, dy / magnitude); + } catch (error) { + errores.push(String(error?.message || error)); + ticks++; + } + }, 100); + } + + function iniciarTurnos({ params, world, cargarOverrides }) { + const errores = capturarErrores(); + const total = parseInt(params.get('selftest'), 10) || 100; + cargarOverrides(); + window.Game.startRun(params.get('seed') || 'selftest'); + if (params.get('arma')) { + world.player.inv.push('fuego_griego', 'detector'); + world.player.manos[0] = 'tuberia'; + } + setTimeout(() => document.getElementById('btn-enter')?.click(), 30); + let acciones = 0; + let marchaCache = null; + const directions = [[0, -1], [0, 1], [-1, 0], [1, 0]]; + + const interval = setInterval(() => { + try { + if (params.get('remodel') && acciones === 120 && !world.over) { + window.__remodelResultado = []; + for (let i = 0; i < 5; i++) window.__remodelResultado.push(world.remodelarZona()); + } + if (acciones >= total || world.over) { + clearInterval(interval); + publicar({ + acciones, + nivel: world.level?.id, + visitados: world.visited, + turnoTotal: world.turnTotal, + pasosNivel: world.pasosNivel, + objetivoCaminata: world._caminataObjetivo, + posicion: [world.player?.x, world.player?.y], + mapa: world.map ? [world.map.grid.w, world.map.grid.h] : null, + salud: world.player?.salud, + cordura: world.player?.cordura, + inv: world.player?.inv, + entidadesVivas: world.entities.filter((entity) => entity.viva).length, + over: world.over, + diario: world.journal.map((entry) => entry.nombre), + errores, + erroresRender: window.__renderErrors || [], + remodel: window.__remodelResultado || null, + ventanas: world.ventanaN || 0, + }, errores); + if (params.get('codex')) world.ui.toggleCodex(true); + return; + } + const card = document.getElementById('screen-card'); + if (card.style.display !== 'none') { document.getElementById('btn-enter').click(); return; } + const exitModal = document.getElementById('exit-modal'); + if (exitModal.style.display !== 'none') { + const button = Math.random() < 0.7 + ? document.getElementById('btn-cross') : document.getElementById('btn-stay'); + if (button?.style.display !== 'none') button.click(); + else document.getElementById('btn-stay').click(); + acciones++; + return; + } + const choiceModal = document.getElementById('choice-modal'); + if (choiceModal?.style.display !== 'none') { + const buttons = document.querySelectorAll('#choice-btns button'); + if (buttons.length) buttons[Math.random() < 0.6 ? 0 : buttons.length - 1].click(); + acciones++; + return; + } + if (world.busy) return; + if (params.get('shift') && !window.__shiftForzado) { + const grid = world.map.grid; + let position = null; + for (let x = grid.w - 2; x >= grid.w - 20 && !position; x--) + for (let y = 1; y < grid.h - 1; y++) + if (window.MapGen.walkable(window.MapGen.at(grid, x, y))) { position = [x, y]; break; } + if (position) { + world.player.x = world.player.rx = position[0]; + world.player.y = world.player.ry = position[1]; + window.__shiftForzado = true; + window.Game.wait(); + acciones++; + return; + } + } + if (params.get('marcha')) { + const grid = world.map.grid; + const version = `${world.ventanaN || 0}:${world.mapaVersion || 0}`; + if (!marchaCache || marchaCache.version !== version) { + marchaCache = null; + buscar: for (let targetX = grid.w - 2; targetX >= 1; targetX--) + for (let targetY = 1; targetY < grid.h - 1; targetY++) { + if (!window.MapGen.walkable(window.MapGen.at(grid, targetX, targetY))) continue; + const distances = window.MapGen.bfsDist(grid, targetX, targetY); + if (distances[world.player.y * grid.w + world.player.x] >= 0) { + marchaCache = { version, dist: distances }; + break buscar; + } + } + } + let step = null; + if (marchaCache) { + const current = marchaCache.dist[world.player.y * grid.w + world.player.x]; + for (const [dx, dy] of directions) { + const x = world.player.x + dx; + const y = world.player.y + dy; + if (x < 0 || y < 0 || x >= grid.w || y >= grid.h) continue; + const value = marchaCache.dist[y * grid.w + x]; + if (value >= 0 && value < current) { step = [dx, dy]; break; } + } + } + if (step) window.Game.tryMove(step[0], step[1]); + else { marchaCache = null; window.Game.tryMove(1, 0); } + acciones++; + return; + } + if (params.get('arma')) { + const adjacent = world.entities.find((entity) => entity.viva && + Math.abs(entity.x - world.player.x) + Math.abs(entity.y - world.player.y) === 1); + if (adjacent) { + window.Game.tryMove( + Math.sign(adjacent.x - world.player.x), + Math.sign(adjacent.y - world.player.y) + ); + acciones++; + return; + } + } + let direction = directions[Math.floor(Math.random() * directions.length)]; + if (Math.random() < 0.85 && world.map.exits.length) { + const grid = world.map.grid; + let best = null; + let bestDistance = Infinity; + for (const exit of world.map.exits) { + const distances = window.MapGen.bfsDist(grid, exit.x, exit.y); + const value = distances[world.player.y * grid.w + world.player.x]; + if (value >= 0 && value < bestDistance) { bestDistance = value; best = distances; } + } + if (best) { + for (const [dx, dy] of directions) { + const x = world.player.x + dx; + const y = world.player.y + dy; + if (x < 0 || y < 0 || x >= grid.w || y >= grid.h) continue; + const value = best[y * grid.w + x]; + if (value >= 0 && value < bestDistance) { direction = [dx, dy]; break; } + } + } + } + window.Game.tryMove(direction[0], direction[1]); + acciones++; + } catch (error) { + errores.push(String(error?.message || error)); + acciones++; + } + }, 5); + } + + function init(context) { + const { params } = context; + if (!params.get('selftest')) return; + if (params.get('local') || params.get('online')) iniciarOnline(context); + else iniciarTurnos(context); + } + + window.SelfTest = { init }; +})(); diff --git a/game/js/engine/cargador.js b/game/js/engine/cargador.js new file mode 100644 index 0000000..66afebe --- /dev/null +++ b/game/js/engine/cargador.js @@ -0,0 +1,59 @@ +// Carga de scripts BAJO DEMANDA, sin build tools ni módulos ES. +// El chunk 3D (three.min + shaders + postpro + atmos3d + render3d*) es el 52% +// del JavaScript del juego y la portada no lo usa para nada: su fondo son los +// WebP del panorama. Se inyecta cuando hace falta, no al parsear el HTML. +// +// `async = false` es la clave: los scripts inyectados se DESCARGAN en paralelo +// pero se EJECUTAN en orden de inserción — exactamente la misma garantía que +// da el orden de los