From 2149b5ce7657e858dcd3ce83187ec0103a22b508 Mon Sep 17 00:00:00 2001 From: cmena92 Date: Thu, 29 Oct 2026 02:18:26 -0600 Subject: [PATCH] Companion: final integration and its economy documented (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No new features. The final gate shows the pieces work together, keep existing data and leave the offline app as it was: - An old profile keeps its coins, unlocks, palette, character, stats and name after opening and caring for the companion; a character bought in the core stays the companion's own. - A new profile goes through the welcome, wears a rabbit, a design and a cap, and feeds a fruit; the balance drops exactly what was spent. - Broken JSON, unknown ids, partial data and a future version open the page with defaults and the balance untouched, with no console error. - «Erase everything» in the profile removes every chogan.companero.* key, through the real button and its red confirmation. - No companion file asks the network for anything, each module is in the entry's files for the service worker, and the APK still asks only to vibrate. - Every string in the five languages, Persian right to left, nothing wider than 390 px, and every button named for a screen reader. COMPANERO.md documents the rule (wishes, never needs), the sources and sinks of coins, the price of every item (checked against CATALOGO by a test), the total of 2,770 coins, the data and the decisions. One visual fix: a long name broke out of its card in German; it now breaks at its soft hyphen (Wasser-schwein). --- COMPANERO.md | 102 ++++++++++++++++++ tools/test-companero.js | 183 ++++++++++++++++++++++++++++++++- www/games/companero/index.html | 4 +- 3 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 COMPANERO.md diff --git a/COMPANERO.md b/COMPANERO.md new file mode 100644 index 00000000..fa1f331f --- /dev/null +++ b/COMPANERO.md @@ -0,0 +1,102 @@ +# Mi compañero + +Una mascota que se cuida y se viste con las monedas de los juegos. Es lo único que Capybara agrega al original ([choganhq/chogan](https://github.com/choganhq/chogan)): vive entera en `www/games/companero/`, con su propia entrada en `www/games.json`, y no toca el núcleo (`www/lib/chogan.js`, `www/lib/chogan.css` ni `www/index.html`). + +Épica: [#2](https://github.com/cmena92/chogan/issues/2). + +## La regla de fondo: deseos, nunca carencias + +El compañero puede **querer** cosas, pero nada empeora con el tiempo y nada se pierde por no abrir la app. + +- No hay hambre, suciedad, enfermedad, tristeza ni muerte. +- No hay rachas, notificaciones, fechas límite ni pérdidas. +- Cumplir un deseo es opcional; no cumplirlo no cambia nada. +- El vínculo nunca baja y no da ninguna ventaja en los juegos. + +Las pruebas lo verifican con un reloj simulado: tras 1 hora, 30 días o 1 año fuera, todo está igual que sin ausencia. + +## Economía + +### Fuentes: de dónde salen las monedas + +Una sola, la del original: **terminar partidas**. Cada juego entrega sus monedas al final de una ronda con `C.stats.record({ coins })`, que es la única puerta de entrada al saldo. Lo habitual son **2 a 15 monedas por partida** según el juego y el resultado, más **8** en el desafío diario. + +El compañero **no da monedas**: ni el cuidado, ni los deseos, ni el vínculo. + +### Sumideros: adónde van + +1. **Las paletas y los personajes del original** (Perfil): 150 a 320 monedas cada uno. +2. **La tienda del compañero**: lo permanente se compra una vez; los consumibles, cada vez. + +Las compras pasan por `C.coins.spend`, que solo acepta precios enteros y positivos, y quedan en un registro local sin datos personales. + +### Precios + +| Artículo | Tipo | Detalle | Precio | +|---|---|---|---| +| `capybara` | animal | el de siempre, gratis | 0 | +| `conejo` | animal | | 180 | +| `panda` | animal | | 260 | +| `nutria` | animal | | 340 | +| `natural` | diseño | los colores propios de cada animal | 0 | +| `medianoche` | diseño | | 120 | +| `arcoiris` | diseño | | 150 | +| `manchitas` | diseño | | 180 | +| `rayas` | diseño | | 180 | +| `dorado` | diseño | | 300 | +| `gorra` | prenda | cabeza | 90 | +| `corona` | prenda | cabeza | 220 | +| `lentes` | prenda | cabeza | 140 | +| `lazo` | prenda | cuello | 80 | +| `panuelo` | prenda | cuello | 110 | +| `sueter` | prenda | cuerpo | 180 | +| `capa` | prenda | cuerpo | 240 | +| `fruta` | consumible | comida | 5 | +| `zanahoria` | consumible | comida | 8 | +| `pastel` | consumible | comida | 15 | +| `jabon` | consumible | baño de burbujas | 10 | + +Los cuatro personajes del original (zorro, búho, gato y oso) también son animales del compañero. Se compran en la tienda del Perfil, no aquí, y el compañero solo lee si ya son tuyos. + +### Costo total + +Todo lo permanente cuesta **2,770 monedas**: animales 780, diseños 930 y ropa 1,060. A unas 8 monedas por partida, son unas 350 partidas: meses de juego tranquilo, sin necesidad de apurarse. + +Los consumibles no suman a ese total: cuestan de 5 a 15 y son opcionales. + +## Cuidado y vínculo + +- **Gratis:** acariciar, cepillar y jugar. +- **Vínculo:** +1 por cada cuidado y +3 extra al cumplir el deseo de la visita. Una misma acción gratuita suma como mucho una vez cada 30 segundos, pero se puede hacer siempre. +- **Cinco niveles**, a los 0, 10, 25, 50 y 100 puntos. El 100 es el máximo y se muestra desde el principio. +- **Un deseo por visita**, distinto del de la visita anterior: probar una comida, un baño de burbujas, algo nuevo para ponerse, jugar o caricias. + +## Datos + +Todo se guarda en el teléfono, bajo `chogan.companero.`: + +- `inventario`: lo comprado, lo equipado y el registro de compras; +- `cuidado`: el vínculo y el deseo de la visita. + +«Borrar todo» en el Perfil lo elimina, como a los datos de cualquier juego. Los datos rotos o de una versión desconocida vuelven a los valores por defecto sin tocar el saldo. El compañero no pide nada a la red ni agrega permisos al APK. + +## Decisiones + +| Decisión | Por qué | +|---|---| +| Deseos en vez de necesidades | Las mascotas virtuales suelen enganchar con culpa; Capybara promete no hacerlo. | +| El compañero es una entrada más de `games.json` | El núcleo queda intacto y sincronizar con el original no da conflictos. | +| Lógica pura en módulos propios (`economia.js`, `cuidado.js`, `disenos.js`, `prendas.js`, `expresiones.js`) | Se prueba sin navegador, con el reloj y el azar simulados. | +| Una sola fuente para la app y el arte | `design/companero/` usa los mismos módulos, así lo aprobado es exactamente lo que se ve. | +| Pruebas en `tools/test-companero.js` | `tools/test.js` es del original; editarlo daría conflictos. | +| Documentación aquí y no en `DECISIONS.md` | Ese archivo es del original. | +| Precios en monedas, sin dinero real | Capybara no tiene compras dentro de la app. | +| En el sitio de WebCom el compañero no cuenta como juego | No es un juego: no se gana ni se pierde. | + +## Cómo se prueba + +```sh +node tools/test-companero.js # el compañero +node tools/test.js # el original +./tools/browser-check.sh # todas las páginas, sin errores en consola +``` diff --git a/tools/test-companero.js b/tools/test-companero.js index 89a926ce..c2201c29 100644 --- a/tools/test-companero.js +++ b/tools/test-companero.js @@ -431,17 +431,25 @@ async function navegador(fn) { await new Promise((r) => ws.addEventListener('open', r, { once: true })); let n = 0; const pendientes = {}; - ws.addEventListener('message', (m) => { const d = JSON.parse(m.data); if (pendientes[d.id]) { pendientes[d.id](d); delete pendientes[d.id]; } }); + const errores = []; + ws.addEventListener('message', (m) => { + const d = JSON.parse(m.data); + if (pendientes[d.id]) { pendientes[d.id](d); delete pendientes[d.id]; } + if (d.method === 'Runtime.exceptionThrown') errores.push(d.params.exceptionDetails.exception?.description || d.params.exceptionDetails.text); + if (d.method === 'Runtime.consoleAPICalled' && d.params.type === 'error') errores.push(d.params.args.map((x) => x.value || x.description).join(' ')); + }); const cdp = (method, params) => new Promise((r) => { const id = ++n; pendientes[id] = r; ws.send(JSON.stringify({ id, method, params: params || {} })); }); const evaluar = async (expr) => (await cdp('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result.result.value; - const abrir = async (ruta) => { + const abrir = async (ruta, listo) => { await cdp('Page.navigate', { url: 'http://127.0.0.1:' + puerto + ruta }); - for (let i = 0; i < 50; i++) { await espera(150); if (await evaluate("document.readyState === 'complete' && !!document.querySelector('[data-seccion]')")) return true; } + const sel = JSON.stringify(listo || '[data-seccion]'); + for (let i = 0; i < 50; i++) { await espera(150); if (await evaluate("document.readyState === 'complete' && !!document.querySelector(" + sel + ")")) return true; } return false; async function evaluate(e) { return evaluar(e); } }; await cdp('Page.enable'); - return await fn({ abrir, evaluar, cdp, origen: 'http://127.0.0.1:' + puerto }); + await cdp('Runtime.enable'); + return await fn({ abrir, evaluar, cdp, errores, origen: 'http://127.0.0.1:' + puerto }); } finally { chrome.kill(); servidor.kill(); await espera(300); @@ -611,6 +619,173 @@ function testEstados() { ok(/juega/i.test(T.es.sinMonedas) && /play/i.test(T.en.sinMonedas), 'sin monedas dice qué hacer: jugar para ganarlas'); } +/* ------------------------------------------------ final integration (#10) */ + +const LANGS = ['fa', 'en', 'zh', 'de', 'es']; +const textos = () => { + const html = fs.readFileSync(PAGINA, 'utf8'); + const bloque = (html.match(/\/\* ==== TEXTOS START ==== \*\/([\s\S]*?)\/\* ==== TEXTOS END ==== \*\//) || [])[1]; + return vm.runInNewContext('(' + bloque.slice(bloque.indexOf('{'), bloque.lastIndexOf('}') + 1) + ')'); +}; +// Every chogan.app.* key, as stored +// What a player already has: opening any game also creates the core's own league and +// recent keys, so those are left out +const nucleoGuardado = `JSON.stringify(['coins', 'unlocks', 'settings', 'stats', 'welcomeSeen'].map((k) => [k, localStorage.getItem('chogan.app.' + k)]).concat([['nombre', JSON.parse(localStorage.getItem('chogan.app.profile') || '{}').name]]))`; + +function testTextosCompletos() { + head('Las cadenas del compañero, completas en los cinco idiomas (#10)'); + const T = textos(); + const claves = Object.keys(T.es); + for (const l of LANGS) { + const faltan = claves.filter((k) => typeof T[l][k] !== 'string' || !T[l][k].trim()); + const sobran = Object.keys(T[l]).filter((k) => !(k in T.es)); + ok(!faltan.length && !sobran.length, l + ': las ' + claves.length + ' cadenas, ni una de más ni de menos' + (faltan.length ? ' (faltan: ' + faltan.join(', ') + ')' : '') + (sobran.length ? ' (sobran: ' + sobran.join(', ') + ')' : '')); + } + const usadas = [...new Set([...fs.readFileSync(PAGINA, 'utf8').matchAll(/C\.t\('([a-zA-Z_]+)'/g)].map((m) => m[1]))]; + const core = fs.readFileSync(path.join(ROOT, 'www/lib/chogan.js'), 'utf8'); + // keys built from an id, like C.t('deseo_' + id), are checked by their own groups + const huerfanas = usadas.filter((k) => !/_$/.test(k)).filter((k) => !(k in T.es) && !new RegExp('[{,\\s]' + k + ':').test(core)); + ok(!huerfanas.length, 'cada C.t() de la página tiene su cadena, propia o del núcleo' + (huerfanas.length ? ' (sin texto: ' + huerfanas.join(', ') + ')' : '')); +} + +function testSinRedNiPermisos() { + head('Sin red, sin permisos y sin rastreo nuevos (#10)'); + const dir = path.join(ROOT, 'www/games/companero'); + const archivos = fs.readdirSync(dir).filter((f) => /\.(js|html)$/.test(f)); + const RED = /\bfetch\s*\(|XMLHttpRequest|WebSocket|sendBeacon|EventSource|navigator\.(geolocation|mediaDevices)|importScripts|https?:\/\/(?!www\.w3\.org\/)/; + const malos = archivos.filter((f) => RED.test(fs.readFileSync(path.join(dir, f), 'utf8'))); + ok(archivos.length >= 7 && !malos.length, 'ninguno de los ' + archivos.length + ' archivos del compañero pide nada a la red' + (malos.length ? ' (' + malos.join(', ') + ')' : '')); + const g = entradas().find((x) => x.id === 'companero'); + const propios = archivos.filter((f) => f !== 'index.html').map((f) => 'games/companero/' + f); + ok(propios.every((f) => g.files.includes(f)), 'files lista cada módulo del compañero: el service worker los guarda para jugar sin conexión'); + const manifiesto = fs.readFileSync(path.join(ROOT, 'android/app/src/main/AndroidManifest.xml'), 'utf8'); + const permisos = [...manifiesto.matchAll(/uses-permission[^>]*android:name="([^"]+)"/g)].map((m) => m[1]); + ok(permisos.join() === 'android.permission.VIBRATE', 'el APK pide solo vibrar, como el original (' + permisos.join(', ') + ')'); + const sw = fs.readFileSync(path.join(ROOT, 'www/sw.js'), 'utf8'); + ok(/games\.json/.test(sw) && /files/.test(sw), 'el service worker guarda los files de cada entrada de games.json'); +} + +function testEconomiaDocumentada() { + head('La economía final, documentada en COMPANERO.md (#10)'); + const doc = path.join(ROOT, 'COMPANERO.md'); + ok(fs.existsSync(doc), 'COMPANERO.md existe en la raíz del fork'); + if (!fs.existsSync(doc) || !E) return; + const md = fs.readFileSync(doc, 'utf8'); + const filas = new Map([...md.matchAll(/^\|\s*`([a-z]+)`\s*\|[^|\n]*\|[^|\n]*\|\s*(\d+)\s*\|/gm)].map((m) => [m[1], +m[2]])); + const malos = E.CATALOGO.filter((a) => filas.get(a.id) !== a.precio); + ok(filas.size === E.CATALOGO.length && !malos.length, 'la tabla de precios coincide con CATALOGO, artículo por artículo (' + filas.size + ' filas)' + (malos.length ? ' (distintos: ' + malos.map((a) => a.id).join(', ') + ')' : '')); + const total = E.CATALOGO.filter((a) => a.tipo !== 'consumible').reduce((t, a) => t + a.precio, 0); + ok(new RegExp('\\*\\*' + total.toLocaleString('en-US') + ' monedas\\*\\*').test(md), 'dice el costo total de todo lo permanente: ' + total + ' monedas'); + ok(/fuentes/i.test(md) && /sumideros/i.test(md) && /stats\.record/.test(md), 'explica de dónde salen las monedas (solo al terminar partidas) y adónde van'); +} + +async function testPerfilAnterior() { + head('Un perfil anterior no pierde nada (#10)'); + await navegador(async ({ abrir, evaluar }) => { + await abrir('/games/companero/index.html'); + await evaluar(preparar({ monedas: 77, unlocks: ['palette.sea', 'mascot.owl'], settings: { palette: 'sea', mascot: 'owl', theme: 'dark' } })); + await evaluar(`localStorage.setItem('chogan.app.stats', JSON.stringify({ plays: 40, timeMs: 9e6, byGame: { simon: { plays: 40, wins: 12, timeMs: 9e6, best: {} } }, streak: { count: 3 } })); localStorage.setItem('chogan.app.profile', JSON.stringify({ name: 'Ana' })); localStorage.setItem('chogan.app.welcomeSeen', 'true'); true`); + const antes = await evaluar(nucleoGuardado); + ok(await evaluar(`Object.keys(localStorage).every((k) => k.indexOf('chogan.companero.') < 0)`), 'el perfil viene de antes del compañero: ninguna clave chogan.companero.'); + await abrir('/games/companero/index.html'); + await evaluar(`document.querySelector('[data-accion="acariciar"]').click(); document.querySelector('[data-accion="jugar"]').click(); true`); + await abrir('/games/companero/index.html'); + ok(await evaluar(nucleoGuardado) === antes, 'abrirlo y cuidar al compañero deja monedas, desbloqueos, paleta, personaje, estadísticas y perfil intactos'); + ok(await evaluar(`document.querySelector('[data-animal="owl"]').getAttribute('aria-pressed') !== null && !document.querySelector('[data-animal="owl"] .cp-precio')`), 'el búho comprado en el núcleo sigue siendo suyo en «Mi compañero»'); + }); +} + +async function testPerfilNuevo() { + head('Un perfil nuevo: bienvenida, compañero, ropa y comida (#10)'); + await navegador(async ({ abrir, evaluar, errores }) => { + await abrir('/index.html', 'body'); + await evaluar(`localStorage.clear(); true`); + await abrir('/index.html', '.ch-welcome__foot .ch-btn--primary'); + let pasos = 0; + while (pasos < 8 && await evaluar(`!!document.querySelector('.ch-welcome__foot .ch-btn--primary')`)) { + await evaluar(`document.querySelector('.ch-welcome__foot .ch-btn--primary').click(); true`); + await evaluar(pausa(250)); pasos++; + } + ok(await evaluar(`localStorage.getItem('chogan.app.welcomeSeen') === 'true' && !document.querySelector('.ch-welcome__foot')`), 'la bienvenida se completa (' + pasos + ' pasos)'); + await evaluar(`localStorage.setItem('chogan.app.coins', '400'); true`); + await abrir('/games/companero/index.html'); + const clic = async (sel) => { + await evaluar(`document.querySelector('${sel}').click(); true`); await evaluar(pausa(200)); + if (await evaluar(`!!document.querySelector('.ch-scrim')`)) { await evaluar(aceptar); await evaluar(pausa(250)); } + }; + await clic('[data-animal="conejo"]'); + await clic('[data-diseno="medianoche"]'); + await clic('[data-prenda-item="gorra"]'); + await clic('[data-consumible="fruta"]'); + const r = await evaluar(`({ eq: JSON.parse(localStorage.getItem('chogan.companero.inventario')).equipado, monedas: +localStorage.getItem('chogan.app.coins'), fruta: JSON.parse(localStorage.getItem('chogan.companero.inventario')).consumibles.fruta || 0 })`); + ok(r.eq.animal === 'conejo' && r.eq.diseno === 'medianoche' && r.eq.prendas.cabeza === 'gorra', 'equipa conejo, medianoche y gorra'); + ok(r.monedas === 400 - 180 - 120 - 90 - 5 && r.fruta === 0, 'compra y usa una fruta; el saldo baja exactamente lo gastado (' + r.monedas + ')'); + ok(!errores.length, 'ni un error en la consola' + (errores.length ? ': ' + errores.slice(0, 2).join(' | ') : '')); + }); +} + +async function testDatosCorruptos() { + head('Datos rotos o ids desconocidos: valores por defecto, saldo intacto (#10)'); + await navegador(async ({ abrir, evaluar, errores }) => { + const casos = [ + ['JSON roto', `localStorage.setItem('chogan.companero.inventario', '{basura'); localStorage.setItem('chogan.companero.cuidado', 'nul')`], + ['ids desconocidos', `localStorage.setItem('chogan.companero.inventario', JSON.stringify({ version: 1, permanentes: ['dragon', 'neon'], consumibles: { caviar: 3 }, equipado: { animal: 'dragon', diseno: 'neon', prendas: { cabeza: 'sombrero', alas: 'capa' } }, movimientos: [] })); localStorage.setItem('chogan.companero.cuidado', JSON.stringify({ version: 1, puntos: 12, ultimas: {}, deseo: { id: 'caviar' }, ultimoDeseo: 'caviar' }))`], + ['datos parciales', `localStorage.setItem('chogan.companero.inventario', JSON.stringify({ version: 1, permanentes: ['lazo'] })); localStorage.setItem('chogan.companero.cuidado', JSON.stringify({ version: 1, puntos: -4 }))`], + ['versión futura', `localStorage.setItem('chogan.companero.inventario', JSON.stringify({ version: 9 })); localStorage.setItem('chogan.companero.cuidado', JSON.stringify({ version: 9, puntos: 999 }))`], + ]; + for (const [nombre, js] of casos) { + await abrir('/games/companero/index.html'); + await evaluar(preparar({ monedas: 50 })); + await evaluar(js + '; true'); + errores.length = 0; + await abrir('/games/companero/index.html'); + const r = await evaluar(`({ monedas: +localStorage.getItem('chogan.app.coins'), cara: !!document.querySelector('.cp-compa svg'), deseo: !!(document.querySelector('[data-deseo]') || {}).dataset?.deseo, vinculo: +document.querySelector('[data-vinculo]').dataset.vinculo })`); + ok(r.monedas === 50 && r.cara && r.deseo && r.vinculo >= 0 && r.vinculo <= 100 && !errores.length, nombre + ': la página abre con el compañero, un deseo y el saldo de 50 intacto' + (errores.length ? ' (' + errores[0] + ')' : '')); + } + }); +} + +async function testRtlYAccesible() { + head('Persa (RTL) a 390 px y nombres accesibles (#10)'); + await navegador(async ({ abrir, evaluar, cdp }) => { + await cdp('Emulation.setDeviceMetricsOverride', { width: 390, height: 900, deviceScaleFactor: 1, mobile: true }); + for (const [l, ancho] of LANGS.map((x) => [x, 390]).concat([['de', 1280]])) { + await cdp('Emulation.setDeviceMetricsOverride', { width: ancho, height: 900, deviceScaleFactor: 1, mobile: ancho < 500 }); + await abrir('/games/companero/index.html'); + await evaluar(preparar({ lang: l, monedas: 0 })); + await abrir('/games/companero/index.html'); + const r = await evaluar(`({ dir: document.documentElement.dir, ancho: document.documentElement.scrollWidth, sinNombre: [...document.querySelectorAll('button, [role=button], a[href]')].filter((b) => !(b.getAttribute('aria-label') || b.textContent).trim()).length, botones: document.querySelectorAll('button').length })`); + ok(r.ancho <= ancho && (l !== 'fa' || r.dir === 'rtl'), l + ': nada se sale del ancho de ' + ancho + ' px' + (l === 'fa' ? ', de derecha a izquierda' : '') + ' (' + r.ancho + ' px)'); + ok(await evaluar(`[...document.querySelectorAll('.cp-item > span')].every((x) => { const c = x.parentElement.getBoundingClientRect(), t = x.getBoundingClientRect(); return t.left >= c.left - 1 && t.right <= c.right + 1; })`), l + ' a ' + ancho + ' px: cada nombre cabe dentro de su tarjeta'); + ok(r.botones > 20 && r.sinNombre === 0, l + ': los ' + r.botones + ' botones tienen nombre para el lector de pantalla'); + } + }); +} + +async function testBorrarTodo() { + head('«Borrar todo» en Ajustes elimina todo el compañero (#10)'); + await navegador(async ({ abrir, evaluar }) => { + await abrir('/games/companero/index.html'); + await evaluar(preparar({ monedas: 300 })); + await abrir('/games/companero/index.html'); + await evaluar(`document.querySelector('[data-accion="acariciar"]').click(); true`); + await evaluar(`document.querySelector('[data-consumible="fruta"]').click(); true`); await evaluar(pausa(200)); await evaluar(aceptar); + ok(await evaluar(`['inventario', 'cuidado'].every((k) => localStorage.getItem('chogan.companero.' + k))`), 'antes: hay inventario y cuidado guardados'); + await abrir('/index.html', '#navIn button'); + // the danger button lives on one of the menu's tabs + const tabs = await evaluar(`document.querySelectorAll('#navIn button').length`); + for (let i = 0; i < tabs && !(await evaluar(`!!document.querySelector('.ch-btn--danger')`)); i++) { + await evaluar(`document.querySelectorAll('#navIn button')[${i}].click(); true`); await evaluar(pausa(300)); + } + await evaluar(`document.querySelector('.ch-btn--danger').click(); true`); await evaluar(pausa(300)); + // the confirmation of an erase is red, not the usual primary + ok(await evaluar(`(() => { const b = document.querySelector('.ch-scrim .ch-btn--danger'); if (b) b.click(); return !!b; })()`), 'pide confirmación con un botón rojo'); + await evaluar(pausa(1500)); + const quedan = await evaluar(`Object.keys(localStorage).filter((k) => k.indexOf('chogan.companero.') === 0)`); + ok(Array.isArray(quedan) && quedan.length === 0, 'después: no queda ninguna clave chogan.companero.' + (quedan && quedan.length ? ' (' + quedan.join(', ') + ')' : '')); + }); +} + /* ------------------------------------------------ run */ const GROUPS = [...fs.readFileSync(__filename, 'utf8').matchAll(/^(?:async )?function (test[A-Za-z0-9]+)\(\) \{/gm)].map((m) => m[1]); diff --git a/www/games/companero/index.html b/www/games/companero/index.html index 7b2fdf2f..c56e4e33 100644 --- a/www/games/companero/index.html +++ b/www/games/companero/index.html @@ -18,6 +18,8 @@ .cp-item { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: var(--s-2); min-height: 44px; border: 2px solid transparent; border-radius: var(--r-md); background: var(--c-surface); color: inherit; font: inherit; cursor: pointer; } .cp-item svg { width: 56px; height: auto; } + /* a long name never leaves its card: it breaks at its soft hyphen (Wasser\u00ADschwein), or anywhere if it has none */ + .cp-item > span { max-width: 100%; text-align: center; hyphens: auto; overflow-wrap: anywhere; } .cp-item[aria-pressed="true"] { border-color: var(--game); } .cp-item:focus-visible { outline: 3px solid var(--game); outline-offset: 2px; } .cp-nota { font-size: .8rem; opacity: .75; } @@ -80,7 +82,7 @@ desbloquear: 'Für {n} Münzen freischalten?', desbloquearBtn: 'Freischalten', faltan: 'Dir fehlen {n} Münzen', jugarParaGanar: 'Spiel eine Runde, um welche zu verdienen.', enPerfil: 'Im Profil freischaltbar', precio: '{n} Münzen', tuyo: 'Deins', monedas: 'Münzen', animales: 'Tiere', colores: 'Farben', ropa: 'Kleidung', cuidado: 'Pflege', cargando: 'Dein Begleiter wird vorbereitet …', vacio: 'Hier ist noch nichts.', sinMonedas: 'Du hast noch keine Münzen. Spiel eine Runde, um welche zu verdienen.', equipado: 'In Gebrauch', - a_fox: 'Fuchs', a_owl: 'Eule', a_cat: 'Katze', a_bear: 'Bär', a_capybara: 'Wasserschwein', a_conejo: 'Hase', a_panda: 'Panda', a_nutria: 'Otter' }, + a_fox: 'Fuchs', a_owl: 'Eule', a_cat: 'Katze', a_bear: 'Bär', a_capybara: 'Wasser\u00ADschwein', a_conejo: 'Hase', a_panda: 'Panda', a_nutria: 'Otter' }, es: { acariciar: 'Acariciar', cepillar: 'Cepillar', jugar: 'Jugar', c_fruta: 'Fruta', c_zanahoria: 'Zanahoria', c_pastel: 'Pastel', c_jabon: 'Jabón de burbujas', cantidad: '×{n}', vinculo: 'Vínculo: nivel {n} de {m}', deseoCumplido: '¡Gracias! Me encantó.', deseo_fruta: '¡Me gustaría probar una fruta!', deseo_zanahoria: '¡Me gustaría probar la zanahoria!', deseo_pastel: '¡Qué rico sería un pastel!', deseo_banio: '¡Me encantaría un baño de burbujas!', deseo_ropa: '¡Me vería bien con algo nuevo!', deseo_jugar: '¡Juguemos un rato!', deseo_caricias: '¿Me das unas caricias?', p_gorra: 'Gorra', p_corona: 'Corona', p_lentes: 'Lentes', p_lazo: 'Lazo', p_panuelo: 'Pañuelo', p_sueter: 'Suéter', p_capa: 'Capa', e_cabeza: 'Cabeza', e_cuello: 'Cuello', e_cuerpo: 'Cuerpo', ninguna: 'Ninguna', ariaPrecio: '{nombre}, {precio}', ariaTuyo: '{nombre}, tuyo', ariaEnUso: '{nombre}, en uso', ariaPerfil: '{nombre}, se desbloquea en tu perfil', d_natural: 'Natural', d_medianoche: 'Medianoche', d_arcoiris: 'Arcoíris suave', d_manchitas: 'Manchitas', d_rayas: 'Rayas', d_dorado: 'Dorado',