Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions COMPANERO.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ Los consumibles no suman a ese total: cuestan de 5 a 15 y son opcionales.
- **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. Primero se elige el tipo, todos igual de probables, y después cuál comida: así las siete comidas no acaparan los deseos.
- **Ganas (#39):** de comer, de jugar y de bañarse, de 0 a 3. Suben una cada 3, 4 y 5 partidas terminadas en toda la app (leídas de las estadísticas del núcleo, que el compañero nunca escribe). **Crecen jugando, nunca por el tiempo fuera:** un año sin jugar las deja como estaban. En 3, el globo lo dice («¡Uf, tanto mirarte jugar me abrió el apetito!») y la cara es antojada. Atenderlas las baja a 0, y si estaban en 2 o más suman +2 de vínculo. Si nadie las atiende no pasa nada.
- **Tocarlo:** responde con risa, guiño, sonrojo o un giro, nunca dos veces seguidas igual, y vuelve a mostrar su deseo. Cinco toques en 3 segundos lo marean un momento: es un gesto gracioso, no un castigo. Suma vínculo con la regla de 30 segundos y no da monedas.
- **Escenas:** comer lleva la comida a la boca, el jabón da un baño de burbujas y jugar trae una pelota que rebota. Al comer mastica («ñam») y en el baño se sonroja. Con movimiento reducido se ve la misma escena, quieta.

Expand All @@ -102,6 +103,7 @@ Todo se guarda en el teléfono, bajo `chogan.companero.`:
| Decisión | Por qué |
|---|---|
| Deseos en vez de necesidades | Las mascotas virtuales suelen enganchar con culpa; Capybara promete no hacerlo. |
| Ganas que crecen jugando, no esperando | El compañero reacciona a lo que hacés sin castigar a quien no entra (decisión del 2026-11-03). |
| 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. |
Expand Down
67 changes: 64 additions & 3 deletions tools/test-companero.js
Original file line number Diff line number Diff line change
Expand Up @@ -318,9 +318,11 @@ function companero(o) {
const x = nuevo({ saldo: o.saldo === undefined ? 500 : o.saldo, datos: o.datos });
let t = o.t || 1e12;
let dado = o.dado || (() => 0);
const crearCuidado = () => K.crear({ store: x.s, economia: x.eco, ahora: () => t, azar: () => dado() });
// the games finished in the whole app, as the core counts them (C.stats.get().plays)
let jugadas = o.partidas || 0;
const crearCuidado = () => K.crear({ store: x.s, economia: x.eco, ahora: () => t, azar: () => dado(), partidas: () => jugadas });
const c = crearCuidado();
return Object.assign(x, { c, crearCuidado, pasar(ms) { t += ms; }, dado(f) { dado = f; } });
return Object.assign(x, { c, crearCuidado, pasar(ms) { t += ms; }, dado(f) { dado = f; }, jugar(n) { jugadas += n; } });
}

function testCuidadoLogica() {
Expand Down Expand Up @@ -574,7 +576,7 @@ function testDeseosSinCarencia() {
zh: ['饿', '伤心', '难过', '脏', '孤单', '寂寞', '想你'],
fa: ['گرسنه', 'غمگین', 'ناراحت', 'کثیف', 'تنها', 'دلتنگ'],
};
const claves = (K ? K.DESEOS.map((d) => 'deseo_' + d.id) : []).concat(['deseoCumplido']);
const claves = (K ? K.DESEOS.map((d) => 'deseo_' + d.id) : []).concat(['deseoCumplido', 'gana_comer', 'gana_jugar', 'gana_banio']);
for (const l of Object.keys(PROHIBIDAS)) {
const textos = claves.map((k) => T[l] && T[l][k]);
ok(textos.every((x) => typeof x === 'string' && x.length > 2), l + ': cada deseo tiene su texto');
Expand Down Expand Up @@ -1003,6 +1005,65 @@ async function testRopaNueva() {
});
}

/* ------------------------------------------------ wanting things, as you play (#39) */

function testGanas() {
head('Ganas de comer, jugar y bañarse: crecen jugando, nunca por ausencia (#39)');
if (!K || !K.GANAS) return ok(false, 'cuidado.js exporta GANAS');
ok(JSON.stringify(K.GANAS) === JSON.stringify({ comer: 3, jugar: 4, banio: 5 }), 'una gana sube cada 3 partidas (comer), 4 (jugar) y 5 (bañarse)');
const x = companero({ saldo: 200, partidas: 40 });
ok(JSON.stringify(x.c.ganas()) === '{"comer":0,"jugar":0,"banio":0}', 'un perfil que ya jugaba empieza con las ganas en 0, no con las partidas de antes');
x.jugar(3);
ok(x.c.ganas().comer === 1 && x.c.ganas().jugar === 0, 'tras 3 partidas: comer 1, jugar 0');
x.jugar(2);
ok(JSON.stringify(x.c.ganas()) === '{"comer":1,"jugar":1,"banio":1}', 'tras 5: las tres en 1');
x.jugar(30);
ok(JSON.stringify(x.c.ganas()) === '{"comer":3,"jugar":3,"banio":3}', 'tras 35: las tres en 3, el tope');
x.pasar(365 * 864e5);
ok(JSON.stringify(x.crearCuidado().ganas()) === '{"comer":3,"jugar":3,"banio":3}', 'un año después siguen en 3: nunca bajan solas');

const v0 = x.c.vinculo().puntos;
x.eco.comprar('galleta');
const r = x.c.alimentar('galleta');
ok(x.c.ganas().comer === 0 && x.c.ganas().jugar === 3, 'cualquier comida baja «comer» a 0, y nada más');
ok(r.atendio === 'comer' && x.c.vinculo().puntos === v0 + 1 + 2, 'atender una gana en 2 o más suma +2 de vínculo extra (' + v0 + ' → ' + x.c.vinculo().puntos + ')');
x.c.hecho({ accion: 'jugar' });
x.eco.comprar('jabon'); x.c.alimentar('jabon');
ok(JSON.stringify(x.c.ganas()) === '{"comer":0,"jugar":0,"banio":0}', 'jugar baja «jugar» y el jabón, «bañarse»');
x.jugar(3); const v1 = x.c.vinculo().puntos;
x.pasar(31000); x.eco.comprar('fruta'); x.c.alimentar('fruta');
ok(x.c.vinculo().puntos === v1 + 1, 'una gana en 1 no da el extra');

const y = companero({ partidas: 0 });
y.pasar(365 * 864e5);
ok(JSON.stringify(y.crearCuidado().ganas()) === '{"comer":0,"jugar":0,"banio":0}', 'un año sin jugar: las ganas siguen en 0');
const z = companero({ partidas: 0 });
z.jugar(9);
ok(z.c.ganaFuerte() === 'comer' && z.c.expresionAlAbrir() === 'antojado', 'con una gana en 3, abre antojado y la gana fuerte es la que más creció (comer)');
ok(z.s.escritas.every((k) => k === 'inventario' || k === 'cuidado'), 'las ganas se guardan en «cuidado»: nunca escribe las estadísticas del núcleo');
// an old saved care, from before #39, keeps its bond
const w = nuevo({ saldo: 10, datos: { cuidado: { version: 1, puntos: 42, ultimas: {}, deseo: null, ultimoDeseo: null } } });
const cw = K.crear({ store: w.s, economia: w.eco, partidas: () => 80 });
ok(cw.vinculo().puntos === 42 && JSON.stringify(cw.ganas()) === '{"comer":0,"jugar":0,"banio":0}', 'un cuidado guardado antes de #39 conserva su vínculo y empieza con las ganas en 0');
}

async function testGanasPagina() {
head('Las ganas en la página (#39)');
await navegador(async ({ abrir, evaluar }) => {
await abrir('/games/companero/index.html');
await evaluar(preparar({ monedas: 100 }));
await evaluar(`localStorage.setItem('chogan.app.stats', JSON.stringify({ plays: 12, timeMs: 0, byGame: {}, streak: { count: 0 } })); localStorage.setItem('chogan.companero.cuidado', JSON.stringify({ version: 1, puntos: 5, ultimas: {}, deseo: null, ultimoDeseo: null, ganas: { comer: 0, jugar: 4, banio: 12 } })); true`);
await abrir('/games/companero/index.html');
const g = await evaluar(`[...document.querySelectorAll('.cp-gana')].map((x) => x.dataset.gana + ':' + x.dataset.nivel).join()`);
ok(g === 'comer:3,jugar:2,banio:0', 'tres indicadores con su nivel: comer 3, jugar 2, bañarse 0 (' + g + ')');
ok(await evaluar(`[...document.querySelectorAll('.cp-gana')].every((x) => x.querySelector('svg') && x.getAttribute('aria-label'))`), 'cada indicador tiene su dibujo y un nombre para el lector de pantalla');
ok(await evaluar(`document.querySelector('.cp-globo').dataset.gana === 'comer'`) && await evaluar(`document.querySelector('.cp-compa').dataset.expresion === 'antojado'`), 'con ganas de comer en 3, el globo lo dice y la cara es antojada');
await evaluar(`document.querySelector('[data-consumible="fruta"]').click(); true`); await evaluar(pausa(200)); await evaluar(aceptar); await evaluar(pausa(200));
ok(await evaluar(`document.querySelector('[data-gana="comer"]').dataset.nivel`) === '0', 'al darle de comer, la gana baja a 0');
ok(!(await evaluar(`localStorage.getItem('chogan.app.stats')`)).includes('companero'), 'las estadísticas del núcleo quedan intactas');
});
}

/* ------------------------------------------------ run */

const GROUPS = [...fs.readFileSync(__filename, 'utf8').matchAll(/^(?:async )?function (test[A-Za-z0-9]+)\(\) \{/gm)].map((m) => m[1]);
Expand Down
40 changes: 36 additions & 4 deletions www/games/companero/cuidado.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
var POR_CUIDADO = 1, POR_DESEO = 3;
// the same free action adds to the bond at most once in this long; it is never blocked
var ESPERA_MS = 30000;
// Wanting things (#39): one level more every this many games finished in the whole app, up to 3.
// Games, never time: a year away with no games leaves them as they were.
var GANAS = { comer: 3, jugar: 4, banio: 5 };
var TOPE = 3, POR_GANA = 2;

function vacio() { return { version: VERSION, puntos: 0, ultimas: {}, deseo: null, ultimoDeseo: null }; }
function sano(d) {
Expand All @@ -58,9 +62,28 @@
var store = opts.store, eco = opts.economia;
var ahora = opts.ahora || function () { return Date.now(); };
var azar = opts.azar || Math.random;
var partidas = function () { var n = opts.partidas ? opts.partidas() : 0; return typeof n === 'number' && n >= 0 ? Math.floor(n) : 0; };
var d = store.get('cuidado', null);
if (!sano(d)) d = vacio();
// each gana keeps the game count it was last tended at; a missing one starts now, so the games
// played before #39 never count, and a count above the core's (its stats were erased) comes down
if (!d.ganas || typeof d.ganas !== 'object') d.ganas = {};
var nuevas = false;
Object.keys(GANAS).forEach(function (g) {
var b = d.ganas[g];
if (typeof b !== 'number' || b < 0 || b > partidas()) { d.ganas[g] = partidas(); nuevas = true; }
});
function guardar() { store.set('cuidado', d); }
// kept at once: else the next opening would start them over from its own count
if (nuevas) guardar();
function nivel(g) { return Math.min(TOPE, Math.floor((partidas() - d.ganas[g]) / GANAS[g])); }
function ganas() { var r = {}; Object.keys(GANAS).forEach(function (g) { r[g] = nivel(g); }); return r; }
// the gana at the top, if any, in a fixed order: the bubble says it and the face shows it
function ganaFuerte() {
var gs = Object.keys(GANAS);
for (var i = 0; i < gs.length; i++) if (nivel(gs[i]) >= TOPE) return gs[i];
return null;
}

// A visit is each time the space opens: one wish, never the same as the last visit's
function visitar() {
Expand All @@ -86,9 +109,17 @@
} else if (ev.usar) suma += POR_CUIDADO;
var cumplio = !!(d.deseo && !d.deseo.cumplido && cumple(d.deseo, ev));
if (cumplio) { d.deseo.cumplido = true; suma += POR_DESEO; }
// tending a gana brings it back to 0, with a little extra when it had grown
var gana = ev.usar === 'jabon' ? 'banio' : ev.usar ? 'comer' : ev.accion === 'jugar' || ev.juego ? 'jugar' : null;
var atendio = null;
if (gana) {
if (nivel(gana) >= 2) suma += POR_GANA;
if (nivel(gana) > 0) atendio = gana;
d.ganas[gana] = partidas();
}
d.puntos = Math.min(MAXIMO, d.puntos + suma);
guardar();
return { ok: true, suma: suma, cumplioDeseo: cumplio };
return { ok: true, suma: suma, cumplioDeseo: cumplio, atendio: atendio };
}

// Feeding or a bubble bath: uses one unit from the economy, then counts as care
Expand All @@ -105,10 +136,11 @@
}

// The face on opening depends only on the visit's wish, never on time away
function expresionAlAbrir() { return d.deseo && !d.deseo.cumplido ? 'antojado' : 'contento'; }
function expresionAlAbrir() { return (d.deseo && !d.deseo.cumplido) || ganaFuerte() ? 'antojado' : 'contento'; }

return { visitar: visitar, deseo: deseo, hecho: hecho, alimentar: alimentar, vinculo: vinculo, expresionAlAbrir: expresionAlAbrir };
return { visitar: visitar, deseo: deseo, hecho: hecho, alimentar: alimentar, vinculo: vinculo, expresionAlAbrir: expresionAlAbrir,
ganas: ganas, ganaFuerte: ganaFuerte };
}

return { VERSION: VERSION, DESEOS: DESEOS, ACCIONES: ACCIONES, NIVELES: NIVELES, MAXIMO: MAXIMO, crear: crear };
return { VERSION: VERSION, DESEOS: DESEOS, ACCIONES: ACCIONES, NIVELES: NIVELES, MAXIMO: MAXIMO, GANAS: GANAS, crear: crear };
});
Loading
Loading