boosterarena-mcp 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LEEME.md +3 -1
- package/dist/nucleo.mjs +162 -26
- package/package.json +1 -1
- package/proyectos.mjs +6 -1
- package/servidor.mjs +161 -5
- package/sesion.mjs +91 -10
package/LEEME.md
CHANGED
|
@@ -50,7 +50,9 @@ habla con Booster con **tu** sesión.
|
|
|
50
50
|
## Qué puede hacer
|
|
51
51
|
|
|
52
52
|
**Proyectos** — `listar_proyectos`, `abrir_proyecto`, `crear_proyecto`, `guardar_proyecto`,
|
|
53
|
-
`ver_slide
|
|
53
|
+
`ver_slide`, `ir_a_slide` (volver a un slide ya hecho para corregirlo; `abrir_proyecto` también acepta
|
|
54
|
+
`escena` y `slide`) y `tanda` (muchas órdenes en una sola llamada: es la forma barata de armar un
|
|
55
|
+
slide). Nada se sube hasta que se llama a `guardar_proyecto`: el agente trabaja sobre un
|
|
54
56
|
borrador y tú decides cuándo se publica.
|
|
55
57
|
|
|
56
58
|
**Contenido** — poner texto, imágenes, figuras y dibujos de la biblioteca (más de 3.000
|
package/dist/nucleo.mjs
CHANGED
|
@@ -1587,7 +1587,7 @@ function construirCatalogo(opts) {
|
|
|
1587
1587
|
segundos: num('Solo con evento "time": a los cu\xE1ntos segundos.'),
|
|
1588
1588
|
paso: num('Solo con evento "clickStep": en qu\xE9 n\xFAmero de clic (1, 2, 3...).'),
|
|
1589
1589
|
accion: str(
|
|
1590
|
-
"QU\xC9 pasa. abrirCapa = muestra una capa \xB7 irAEscena = salta a otra escena \xB7 irASlide = salta a un slide \xB7 cerrarCapa = cierra la capa abierta \xB7 leerEnVoz = lo lee el navegador en voz alta \xB7 abrirEnlace = abre una URL \xB7 ocultar = el objeto desaparece con una nube de humo \xB7 moverPorTrayectoria = el objeto RECORRE un camino dibujado \xB7 sonar = suena un audio que ya est\xE1 puesto en el slide o en una capa (para \xABque suene al abrirse la capa\xBB: objetivo = ese audio, evento = layerShown, destino = ese mismo audio; para \xABal tocar X\xBB: objetivo = X, destino = el audio) \xB7 reiniciarSecuencia = devuelve el slide a como empez\xF3 \xB7 quitar = le borra todo lo que tuviera programado.",
|
|
1590
|
+
"QU\xC9 pasa. abrirCapa = muestra una capa \xB7 irAEscena = salta a otra escena \xB7 irASlide = salta a un slide \xB7 cerrarCapa = cierra la capa abierta \xB7 leerEnVoz = lo lee el navegador en voz alta \xB7 abrirEnlace = abre una URL \xB7 ocultar = el objeto desaparece con una nube de humo \xB7 moverPorTrayectoria = el objeto RECORRE un camino dibujado \xB7 sonar = suena un audio que ya est\xE1 puesto en el slide o en una capa (para \xABque suene al abrirse la capa\xBB: objetivo = ese audio, evento = layerShown, destino = ese mismo audio; para \xABal tocar X\xBB: objetivo = X, destino = el audio) \xB7 pausarAudio / detenerAudio = pausa o detiene (vuelve al principio) ese audio; destino = el audio, vac\xEDo = el que est\xE9 sonando \xB7 reiniciarSecuencia = devuelve el slide a como empez\xF3 \xB7 quitar = le borra todo lo que tuviera programado.",
|
|
1591
1591
|
{
|
|
1592
1592
|
enum: [
|
|
1593
1593
|
"abrirCapa",
|
|
@@ -1599,6 +1599,8 @@ function construirCatalogo(opts) {
|
|
|
1599
1599
|
"ocultar",
|
|
1600
1600
|
"moverPorTrayectoria",
|
|
1601
1601
|
"sonar",
|
|
1602
|
+
"pausarAudio",
|
|
1603
|
+
"detenerAudio",
|
|
1602
1604
|
"reiniciarSecuencia",
|
|
1603
1605
|
"quitar"
|
|
1604
1606
|
]
|
|
@@ -1694,7 +1696,10 @@ function construirCatalogo(opts) {
|
|
|
1694
1696
|
var PUNTO = obj({ x: num("En % del ancho del slide."), y: num("En % del alto del slide.") }, ["x", "y"]);
|
|
1695
1697
|
function conLoDelMcp(lista2, fn) {
|
|
1696
1698
|
const props = (nombre) => (lista2.find((t) => t.function.name === nombre)?.function.parameters).properties;
|
|
1699
|
+
const PEDIDO = str('Solo para `borrar` o accion "quitar": lo que te escribi\xF3 la persona pidi\xE9ndolo, COPIADO TAL CUAL (ej: "quita la programaci\xF3n del caldero 3"). Sin una petici\xF3n suya de borrar o quitar, no se borra nada. Nunca lo inventes ni lo escribas t\xFA.');
|
|
1700
|
+
props("borrar").pedido_del_maestro = PEDIDO;
|
|
1697
1701
|
const programar = props("programar");
|
|
1702
|
+
programar.pedido_del_maestro = PEDIDO;
|
|
1698
1703
|
programar.trayectoria = str('Con "moverPorTrayectoria" o el evento "trayectoriaTerminada": el NOMBRE del camino ("Trayectoria 1", "a la boca"). Se busca por nombre EXACTO. Un objeto puede tener varios: dale a cada uno el suyo y as\xED cada `clickStep` o cada `trayectoriaTerminada` apunta al que toca. Si el camino no existe todav\xEDa, hace falta `hasta` o `puntos` para trazarlo.');
|
|
1699
1704
|
programar.hasta = { ...PUNTO, description: 'Solo con "moverPorTrayectoria": a D\xD3NDE llega el CENTRO del objeto, en % del slide. El camino se traza en recta desde donde est\xE9 el objeto \u2014 o, en un `clickStep` 2, 3\u2026, desde donde lo dej\xF3 el paso anterior. Si ya existe un camino con ese `trayectoria`, se retraza.' };
|
|
1700
1705
|
programar.puntos = { type: "array", items: PUNTO, description: 'Solo con "moverPorTrayectoria": puntos por los que PASA el centro del objeto, en % del slide, para una curva o un recorrido con escalas. El \xFAltimo es la llegada (o, si mandas tambi\xE9n `hasta`, `hasta` va detr\xE1s).' };
|
|
@@ -1703,6 +1708,10 @@ function conLoDelMcp(lista2, fn) {
|
|
|
1703
1708
|
fondo.url = str("Una imagen ya SUBIDA (con `subir_imagen`), https://. Usa esto, `busca` o `color`: uno solo.");
|
|
1704
1709
|
fondo.capa = str("El `ref` o el nombre de una CAPA para ponerle el fondo a ella en vez de al slide. Cada capa puede llevar el suyo.");
|
|
1705
1710
|
props("nueva_capa").fondo = str("Fondo propio de la capa: la URL https:// de una imagen ya subida o un color en hex. Sin esto, la capa deja ver el slide de debajo.");
|
|
1711
|
+
for (const t of lista2) {
|
|
1712
|
+
const p = t.function.parameters.properties;
|
|
1713
|
+
if (p.capa === CAPA) p.capa = str("Vac\xEDo = en el slide. Para ponerlo DENTRO de una capa: su `ref`, su id (el que ense\xF1a `ver_slide`) o su nombre exacto. La capa tiene que existir en el slide donde est\xE1s trabajando (cr\xE9ala con `nueva_capa`; para una de otro slide, ve antes con `ir_a_slide`): si no, es error.");
|
|
1714
|
+
}
|
|
1706
1715
|
for (const t of lista2) {
|
|
1707
1716
|
const p = t.function.parameters.properties;
|
|
1708
1717
|
if (!t.function.name.startsWith("poner_") || !p.x || t.function.name === "poner_texto_plantilla") continue;
|
|
@@ -1712,6 +1721,8 @@ function conLoDelMcp(lista2, fn) {
|
|
|
1712
1721
|
const recolocar = props("recolocar");
|
|
1713
1722
|
recolocar.x = num("Nuevo borde izquierdo en %. Puede ser NEGATIVO o pasar de 100 para dejar el objeto FUERA del slide y que entre con una trayectoria (el monstruo que entra solo: x = -50).");
|
|
1714
1723
|
recolocar.y = num("Nuevo borde superior en %. Tambi\xE9n puede quedar fuera del slide, como `x`.");
|
|
1724
|
+
props("poner_figura").alto = num("Alto en % del alto del slide, independiente del ancho. Para los paneles de capa: una elipse blanca de w 82 \xD7 alto 77, o un rect de 83 \xD7 71. Sin esto, la figura guarda su proporci\xF3n de siempre.");
|
|
1725
|
+
recolocar.alto = num("Nuevo alto en %: para cajas (Revelador, Ciclo, QR\u2026), zonas calientes y FIGURAS, independiente del ancho.");
|
|
1715
1726
|
props("cambiar_estilo").gris_al_tocar = bool("true = en el \u25B6 Player, al tocar el objeto se vuelve gris (y otro toque le devuelve el color). Le dice al coach qu\xE9 botones ya us\xF3. false lo quita.");
|
|
1716
1727
|
const i = lista2.findIndex((t) => t.function.name === "poner_emoji");
|
|
1717
1728
|
lista2.splice(i + 1, 0, fn(
|
|
@@ -2056,6 +2067,78 @@ var ESQUEMAS_BOOSTERS = {
|
|
|
2056
2067
|
]
|
|
2057
2068
|
}
|
|
2058
2069
|
},
|
|
2070
|
+
magicshow: {
|
|
2071
|
+
donde: "wcfg.ext",
|
|
2072
|
+
cfgSchema: {
|
|
2073
|
+
campos: {
|
|
2074
|
+
title: texto("T\xEDtulo en el tel\xF3n: \xABWhat is missing?\xBB.", { max: 40 }),
|
|
2075
|
+
items: lista("Las cartas del show. Cada una lleva imagen, palabra o las dos.", objeto("Una carta.", {
|
|
2076
|
+
id: texto("Identificador propio de la carta. Si lo omites, lo pone el editor."),
|
|
2077
|
+
src: imagen("La imagen de la carta."),
|
|
2078
|
+
text: texto("La palabra de la carta.", { max: 30 }),
|
|
2079
|
+
anim: bool2("`src` es una animaci\xF3n Lottie (la carta se mueve sola) y no una imagen.")
|
|
2080
|
+
}), { min: 2, max: 12, obligatorio: true }),
|
|
2081
|
+
icon: objeto("El bot\xF3n m\xE1gico que se toca en clase. Sin \xE9l sale una varita.", {
|
|
2082
|
+
kind: opcion("Figura (imagen) o animaci\xF3n (Lottie).", ["image", "lottie"], { obligatorio: true }),
|
|
2083
|
+
src: texto("URL de la figura o del Lottie.", { obligatorio: true })
|
|
2084
|
+
}),
|
|
2085
|
+
names: bool2("Mostrar la palabra debajo de la imagen."),
|
|
2086
|
+
shuffle: bool2("Barajar las cartas en cada truco."),
|
|
2087
|
+
sound: bool2("Sonido del show.")
|
|
2088
|
+
}
|
|
2089
|
+
},
|
|
2090
|
+
ejemplo: {
|
|
2091
|
+
title: "What is missing?",
|
|
2092
|
+
names: true,
|
|
2093
|
+
shuffle: true,
|
|
2094
|
+
sound: true,
|
|
2095
|
+
items: [{ text: "apple" }, { text: "banana" }, { text: "orange" }, { text: "grapes" }, { text: "pear" }]
|
|
2096
|
+
}
|
|
2097
|
+
},
|
|
2098
|
+
pizarra: {
|
|
2099
|
+
donde: "wcfg.ext",
|
|
2100
|
+
cfgSchema: {
|
|
2101
|
+
campos: {
|
|
2102
|
+
fondo: opcion(
|
|
2103
|
+
"El fondo de la pizarra. \xABvidrio\xBB = cristal esmerilado sobre el slide; \xABtransparente\xBB = se escribe directo sobre el slide; \xABnegro\xBB y \xABverde\xBB = pizarr\xF3n de tiza.",
|
|
2104
|
+
["blanco", "rayas", "cuadricula", "puntos", "vidrio", "transparente", "negro", "verde"]
|
|
2105
|
+
),
|
|
2106
|
+
doc: objeto("Lo que la clase ve escrito al llegar. En el \u25B6 Player se escribe ENCIMA de una copia. Coordenadas en unidades de pizarra: 1600 \xD7 900 (la pizarra cubre el slide entero).", {
|
|
2107
|
+
trazos: lista("Trazos a mano.", objeto("Un trazo.", {
|
|
2108
|
+
id: texto("Identificador propio.", { obligatorio: true }),
|
|
2109
|
+
tipo: opcion("Pluma o resaltador (semitransparente).", ["pluma", "marcador"], { obligatorio: true }),
|
|
2110
|
+
color: color("Color de la tinta."),
|
|
2111
|
+
ancho: num2("Grosor en unidades de pizarra.", { min: 1, max: 120, entero: false, obligatorio: true }),
|
|
2112
|
+
p: lista("Los puntos, planos: x0, y0, x1, y1\u2026", num2("Una coordenada.", { entero: false }), { obligatorio: true })
|
|
2113
|
+
})),
|
|
2114
|
+
textos: lista("Textos tecleados.", objeto("Un texto.", {
|
|
2115
|
+
id: texto("Identificador propio.", { obligatorio: true }),
|
|
2116
|
+
x: num2("Borde izquierdo.", { entero: false, obligatorio: true }),
|
|
2117
|
+
y: num2("Borde de arriba.", { entero: false, obligatorio: true }),
|
|
2118
|
+
texto: texto("Lo escrito. Admite saltos de l\xEDnea.", { obligatorio: true }),
|
|
2119
|
+
color: color("Color del texto."),
|
|
2120
|
+
tam: num2("Tama\xF1o de la letra en unidades de pizarra (62 = medio).", { min: 10, max: 320, entero: false, obligatorio: true })
|
|
2121
|
+
})),
|
|
2122
|
+
imagenes: lista("Im\xE1genes pegadas.", objeto("Una imagen.", {
|
|
2123
|
+
id: texto("Identificador propio.", { obligatorio: true }),
|
|
2124
|
+
src: imagen("La imagen.", { obligatorio: true }),
|
|
2125
|
+
x: num2("Borde izquierdo.", { entero: false, obligatorio: true }),
|
|
2126
|
+
y: num2("Borde de arriba.", { entero: false, obligatorio: true }),
|
|
2127
|
+
w: num2("Ancho.", { entero: false, obligatorio: true }),
|
|
2128
|
+
h: num2("Alto.", { entero: false, obligatorio: true })
|
|
2129
|
+
}))
|
|
2130
|
+
})
|
|
2131
|
+
}
|
|
2132
|
+
},
|
|
2133
|
+
ejemplo: {
|
|
2134
|
+
fondo: "rayas",
|
|
2135
|
+
doc: {
|
|
2136
|
+
trazos: [{ id: "s1", tipo: "marcador", color: "#f59e0b", ancho: 38, p: [110, 200, 700, 200] }],
|
|
2137
|
+
textos: [{ id: "t1", x: 110, y: 120, texto: "Today: the past simple", color: "#111827", tam: 62 }],
|
|
2138
|
+
imagenes: []
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
},
|
|
2059
2142
|
fracplot: {
|
|
2060
2143
|
donde: "wcfg.ext",
|
|
2061
2144
|
cfgSchema: {
|
|
@@ -2888,8 +2971,27 @@ function listaDestino(b, capa) {
|
|
|
2888
2971
|
}
|
|
2889
2972
|
function capaPorNombre(b, ref) {
|
|
2890
2973
|
const s = slideDe(b);
|
|
2974
|
+
return capaExacta(b, s, ref) ?? (b.estricto ? void 0 : s.layers.find((l) => l.name.toLowerCase().includes(ref.toLowerCase())));
|
|
2975
|
+
}
|
|
2976
|
+
function capaExacta(b, s, ref) {
|
|
2977
|
+
const q = ref.trim().toLowerCase();
|
|
2891
2978
|
const id = b.aliasCapa.get(ref);
|
|
2892
|
-
return s.layers.find((l) => l.id === id) ?? s.layers.find((l) => l.
|
|
2979
|
+
return s.layers.find((l) => l.id === id) ?? s.layers.find((l) => l.id === ref) ?? s.layers.find((l) => l.name.trim().toLowerCase() === q);
|
|
2980
|
+
}
|
|
2981
|
+
function capaQueFalta(b, nombre, args) {
|
|
2982
|
+
const ref = txt(args.capa).trim();
|
|
2983
|
+
if (!ref || !nombre.startsWith("poner_") || nombre === "poner_fondo") return null;
|
|
2984
|
+
const s = slideDe(b);
|
|
2985
|
+
if (capaExacta(b, s, ref)) return null;
|
|
2986
|
+
const q = ref.toLowerCase();
|
|
2987
|
+
for (const [i, sc] of b.doc.entries()) for (const [j, sl] of sc.slides.entries()) {
|
|
2988
|
+
if (sl === s) continue;
|
|
2989
|
+
const l = sl.layers.find((x) => x.id === ref || x.name.trim().toLowerCase() === q);
|
|
2990
|
+
if (l) {
|
|
2991
|
+
return `La capa \xAB${l.name}\xBB es del slide ${j + 1} de la escena ${i + 1}, y est\xE1s trabajando en el slide ${b.slide + 1} de la escena ${b.escena + 1}. Ve antes con \`ir_a_slide\` (escena ${i + 1}, slide ${j + 1}). No puse nada.`;
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
return `No hay ninguna capa "${ref}" en este slide (escena ${b.escena + 1}, slide ${b.slide + 1}), y no la creo: un nombre mal escrito esconder\xEDa lo que pones en una capa que nadie abre. ${s.layers.length ? `Las que hay: ${s.layers.map((l) => `\xAB${l.name}\xBB [${l.id}]`).join(", ")}.` : "Este slide no tiene capas."} Cr\xE9ala antes con \`nueva_capa\`. No puse nada.`;
|
|
2893
2995
|
}
|
|
2894
2996
|
function slideDelEl(b, el) {
|
|
2895
2997
|
for (const sc of b.doc) for (const sl of sc.slides) {
|
|
@@ -3016,10 +3118,9 @@ function armarAccion(b, a, el) {
|
|
|
3016
3118
|
const ref = txt(a.capa).trim();
|
|
3017
3119
|
if (b.estricto) {
|
|
3018
3120
|
const sl = slideDelEl(b, el) ?? s;
|
|
3019
|
-
const
|
|
3020
|
-
const capa2 = sl.layers.find((l) => l.id === id) ?? sl.layers.find((l) => l.name.trim().toLowerCase() === ref.toLowerCase());
|
|
3121
|
+
const capa2 = capaExacta(b, sl, ref);
|
|
3021
3122
|
if (!capa2) {
|
|
3022
|
-
return { accion: null, fallo: `${ref ? `No hay ninguna capa "${ref}"` : "Falta `capa`"} en el slide de ese objeto, y no la creo: un nombre mal escrito dejar\xEDa un bot\xF3n que abre una capa en blanco. ${sl.layers.length ? `Las que hay: ${sl.layers.map((l) => `\xAB${l.name}\xBB`).join(", ")}.` : "Ese slide no tiene capas."} Cr\xE9ala antes con \`nueva_capa
|
|
3123
|
+
return { accion: null, fallo: `${ref ? `No hay ninguna capa "${ref}"` : "Falta `capa`"} en el slide de ese objeto, y no la creo: un nombre mal escrito dejar\xEDa un bot\xF3n que abre una capa en blanco. ${sl.layers.length ? `Las que hay: ${sl.layers.map((l) => `\xAB${l.name}\xBB`).join(", ")}.` : "Ese slide no tiene capas."} Cr\xE9ala antes con \`nueva_capa\`.` };
|
|
3023
3124
|
}
|
|
3024
3125
|
return { accion: { type: "showLayer", layerId: capa2.id } };
|
|
3025
3126
|
}
|
|
@@ -3106,6 +3207,15 @@ function armarAccion(b, a, el) {
|
|
|
3106
3207
|
if (audio2.kind !== "audio") return { accion: null, fallo: `"${d}" no es un audio.` };
|
|
3107
3208
|
return { accion: { type: "playSound", targetId: audio2.id } };
|
|
3108
3209
|
}
|
|
3210
|
+
case "pausarAudio":
|
|
3211
|
+
case "detenerAudio": {
|
|
3212
|
+
const d = txt(a.destino).trim();
|
|
3213
|
+
const audio2 = d ? buscarEl(b, d) : el.kind === "audio" ? el : null;
|
|
3214
|
+
if (d && !audio2) return { accion: null, fallo: `No encuentro el audio "${d}".` };
|
|
3215
|
+
if (audio2 && audio2.kind !== "audio") return { accion: null, fallo: `"${d}" no es un audio.` };
|
|
3216
|
+
const type = txt(a.accion) === "pausarAudio" ? "pauseSound" : "stopSound";
|
|
3217
|
+
return { accion: { type, targetId: audio2?.id ?? "" } };
|
|
3218
|
+
}
|
|
3109
3219
|
case "ocultar": {
|
|
3110
3220
|
const d = txt(a.destino).trim();
|
|
3111
3221
|
const otro = d ? buscarEl(b, d) : null;
|
|
@@ -3118,6 +3228,10 @@ function armarAccion(b, a, el) {
|
|
|
3118
3228
|
}
|
|
3119
3229
|
function ejecutar(b, nombre, args, fab) {
|
|
3120
3230
|
const antes = b.creados.length;
|
|
3231
|
+
if (b.estricto) {
|
|
3232
|
+
const falta = capaQueFalta(b, nombre, args);
|
|
3233
|
+
if (falta) return no(b, falta);
|
|
3234
|
+
}
|
|
3121
3235
|
const dijo = ejecutarUna(b, nombre, args, fab);
|
|
3122
3236
|
return b.estricto && !b.fallo ? dijo + dejarFuera(b, nombre, args, antes) : dijo;
|
|
3123
3237
|
}
|
|
@@ -3354,7 +3468,9 @@ El ref "${refBase}" es el rengl\xF3n grande; cada uno es tambi\xE9n "${refBase}_
|
|
|
3354
3468
|
x: pct(args.x, 40),
|
|
3355
3469
|
y: pct(args.y, 35),
|
|
3356
3470
|
w,
|
|
3357
|
-
|
|
3471
|
+
// `alto` libre solo desde el MCP: los paneles de capa de los maestros son elipses de 82 × 77
|
|
3472
|
+
// y rectángulos de 83 × 71, estirados a mano en el editor (Planificatron, corpus 2026-09-30).
|
|
3473
|
+
h: b.estricto && num3(args.alto, 0) > 0 ? pct(args.alto, 20) : def.alto === "cuadrado" ? squareH(w) : squareH(w) * def.alto,
|
|
3358
3474
|
fill: txt(args.color) || def.color,
|
|
3359
3475
|
strokeW: 0,
|
|
3360
3476
|
strokeStyle: "solid",
|
|
@@ -3655,7 +3771,7 @@ Pide \`describir("${el.widget}")\` para ver la forma exacta y vuelve a intentarl
|
|
|
3655
3771
|
case "recolocar": {
|
|
3656
3772
|
const el = buscarEl(b, txt(args.objetivo));
|
|
3657
3773
|
if (!el) return no(b, `No encuentro ning\xFAn objeto "${txt(args.objetivo)}".`);
|
|
3658
|
-
const altoLibre = el.kind === "widget" && !esMoneda(el) || !!el.hotspot;
|
|
3774
|
+
const altoLibre = el.kind === "widget" && !esMoneda(el) || !!el.hotspot || !!b.estricto && el.kind === "shape";
|
|
3659
3775
|
if (num3(args.alto, 0) > 0 && !altoLibre) {
|
|
3660
3776
|
return no(b, `${describirEl(el)} no acepta \`alto\`: se deformar\xEDa. Solo las cajas (Revelador, Ciclo, Label It, QR\u2026) y las zonas calientes. Cambia su tama\xF1o con \`w\` y el alto le sigue.`);
|
|
3661
3777
|
}
|
|
@@ -3795,7 +3911,7 @@ function describirTrigger(t, b) {
|
|
|
3795
3911
|
const cuando = t.event.type === "click" ? "al tocarlo" : t.event.type === "hover" ? "al pasarle por encima" : t.event.type === "slideStart" ? "al entrar al slide" : t.event.type === "layerShown" ? "al abrirse su capa" : t.event.type === "time" ? `a los ${t.event.seconds} s` : t.event.type === "clickStep" ? `en el clic n\xBA ${t.event.step}` : t.event.type === "motionEnd" ? "al terminar su trayectoria" : "al dispararse";
|
|
3796
3912
|
const a = t.action;
|
|
3797
3913
|
const nombreCapa = (id) => buscarCapa(b, id)?.name ?? "una capa";
|
|
3798
|
-
const qu\u00E9 = a.type === "showLayer" ? `abre \xAB${nombreCapa(a.layerId)}\xBB` : a.type === "goScene" ? `salta a \xAB${b.doc.find((s) => s.id === a.sceneId)?.name ?? "otra escena"}\xBB` : a.type === "goSlide" ? "salta a otro slide" : a.type === "closeLayer" ? "cierra la capa" : a.type === "speak" ? `lee \xAB${a.text}\xBB en voz alta` : a.type === "openUrl" ? "abre un enlace" : a.type === "hideEl" ? "desaparece con humo" : a.type === "playMotion" ? "recorre su trayectoria" : a.type === "playSound" ? "suena el audio" : a.type === "resetSequence" ? "reinicia el slide" : "hace lo suyo";
|
|
3914
|
+
const qu\u00E9 = a.type === "showLayer" ? `abre \xAB${nombreCapa(a.layerId)}\xBB` : a.type === "goScene" ? `salta a \xAB${b.doc.find((s) => s.id === a.sceneId)?.name ?? "otra escena"}\xBB` : a.type === "goSlide" ? "salta a otro slide" : a.type === "closeLayer" ? "cierra la capa" : a.type === "speak" ? `lee \xAB${a.text}\xBB en voz alta` : a.type === "openUrl" ? "abre un enlace" : a.type === "hideEl" ? "desaparece con humo" : a.type === "playMotion" ? "recorre su trayectoria" : a.type === "playSound" ? "suena el audio" : a.type === "pauseSound" ? "pausa el audio" : a.type === "stopSound" ? "detiene el audio" : a.type === "resetSequence" ? "reinicia el slide" : "hace lo suyo";
|
|
3799
3915
|
return `${cuando}, ${qu\u00E9}`;
|
|
3800
3916
|
}
|
|
3801
3917
|
|
|
@@ -3836,13 +3952,22 @@ var WORDSTORM_SAMPLE = [
|
|
|
3836
3952
|
var seq = 0;
|
|
3837
3953
|
var cycleUid = () => `cs-${Date.now().toString(36)}-${seq++}`;
|
|
3838
3954
|
|
|
3955
|
+
// ../src/canvas/widgets/MagicShowWidget.datos.ts
|
|
3956
|
+
var seq2 = 0;
|
|
3957
|
+
var magicShowUid = () => `ms-${Date.now().toString(36)}-${seq2++}`;
|
|
3958
|
+
var MAGICSHOW_DEMO = ["apple", "cat", "sun", "book", "star", "fish"];
|
|
3959
|
+
|
|
3960
|
+
// ../src/canvas/widgets/PizarraWidget.datos.ts
|
|
3961
|
+
var pzVacio = () => ({ trazos: [], textos: [], imagenes: [] });
|
|
3962
|
+
var PIZARRA_DEFAULTS = { fondo: "blanco", doc: pzVacio() };
|
|
3963
|
+
|
|
3839
3964
|
// ../src/canvas/widgets/plot2d.ts
|
|
3840
3965
|
var CURVE_COLORS = ["#2563eb", "#dc2626", "#16a34a", "#d97706", "#7c3aed", "#db2777", "#0891b2", "#65a30d"];
|
|
3841
3966
|
|
|
3842
3967
|
// ../src/canvas/widgets/EquationPlotterWidget.datos.ts
|
|
3843
3968
|
var DEFAULT_VIEW = { cx: 0, cy: 0, scale: 46 };
|
|
3844
|
-
var
|
|
3845
|
-
var eid = () => `eq-${Date.now().toString(36)}-${
|
|
3969
|
+
var seq3 = 0;
|
|
3970
|
+
var eid = () => `eq-${Date.now().toString(36)}-${seq3++}`;
|
|
3846
3971
|
function defaultPlotterCfg() {
|
|
3847
3972
|
return {
|
|
3848
3973
|
eqs: [{ id: eid(), src: "y = x^2", color: CURVE_COLORS[0], visible: true }],
|
|
@@ -4122,17 +4247,17 @@ var WIDGETS = [
|
|
|
4122
4247
|
// de la interfaz). El precio del recorte malo es peor que el de la GPU: por eso NO hay carril
|
|
4123
4248
|
// barato de reserva — ver la cabecera de `quitarFondo.ts`.
|
|
4124
4249
|
{ type: "quitafondo", emoji: "\u{1F98E}", label: "Quita Fondos", blurb: "Arr\xE1stralo sobre una imagen y el camale\xF3n se come el fondo. Recorta con el modelo grande: separa mechones de pelo, hojas y patas.", premium: true, ready: true },
|
|
4125
|
-
{ type: "transformar", emoji: "\u{1F500}", label: "Transformador", blurb: "Arr\xE1stralo sobre un objeto y elige en qu\xE9 se convierte. En el \u25B6 Player cada clic lo transforma en la siguiente imagen \u2014de la oruga a la mariposa\u2014 y la \xFAltima lo devuelve a como estaba.", premium:
|
|
4126
|
-
{ type: "magichat", emoji: "\u{1F3A9}", label: "Sombrero M\xE1gico", blurb: "Arr\xE1stralo sobre una imagen (o PNG) para volverla una silueta negra. En el \u25B6 Player, al hacer clic se revela a color con una lluvia de estrellas m\xE1gicas.", premium:
|
|
4250
|
+
{ type: "transformar", emoji: "\u{1F500}", label: "Transformador", blurb: "Arr\xE1stralo sobre un objeto y elige en qu\xE9 se convierte. En el \u25B6 Player cada clic lo transforma en la siguiente imagen \u2014de la oruga a la mariposa\u2014 y la \xFAltima lo devuelve a como estaba.", premium: true, ready: true },
|
|
4251
|
+
{ type: "magichat", emoji: "\u{1F3A9}", label: "Sombrero M\xE1gico", blurb: "Arr\xE1stralo sobre una imagen (o PNG) para volverla una silueta negra. En el \u25B6 Player, al hacer clic se revela a color con una lluvia de estrellas m\xE1gicas.", premium: true, ready: true },
|
|
4127
4252
|
// Tómbola: bombo de bingo que gira y suelta una bola (número/palabra/imagen), sin repetir.
|
|
4128
|
-
{ type: "tombola", emoji: "\u{1F3B0}", label: "T\xF3mbola", blurb: "Bombo de bingo: gira y suelta una bola al azar con un n\xFAmero, palabra o imagen. Sortea sin repetir hasta vaciarse. Ideal para rifas y elegir al azar.", premium:
|
|
4253
|
+
{ type: "tombola", emoji: "\u{1F3B0}", label: "T\xF3mbola", blurb: "Bombo de bingo: gira y suelta una bola al azar con un n\xFAmero, palabra o imagen. Sortea sin repetir hasta vaciarse. Ideal para rifas y elegir al azar.", premium: true, ready: true },
|
|
4129
4254
|
// Semáforo de comportamiento: 3 luces que el maestro toca en el Player para dar feedback a la clase.
|
|
4130
4255
|
{ type: "trafficlight", emoji: "\u{1F6A6}", label: "Sem\xE1foro", blurb: "Sem\xE1foro de comportamiento: en el Player, toca una luz (verde/\xE1mbar/rojo) para dar feedback a la clase. El rojo dispara una alerta sonora y visual.", premium: false, ready: true },
|
|
4131
4256
|
// Graficador de ecuaciones (app-native, rama de MATEMÁTICA): orbe → estudio 2D/3D.
|
|
4132
4257
|
// three.js entra por lazy() solo si el maestro pulsa 3D.
|
|
4133
4258
|
// Label It (app-native, rama de CIENCIAS): la lámina del maestro se vuelve actividad de etiquetar.
|
|
4134
4259
|
// Sirve a biología (célula, flor, corazón), pero también a geografía, anatomía o vocabulario.
|
|
4135
|
-
{ type: "labelit", emoji: "\u{1F3F7}\uFE0F", label: "Label It \u2014 etiquetar", blurb: "Sube cualquier l\xE1mina (c\xE9lula, flor, coraz\xF3n, mapa, una cara\u2026), toca la imagen para clavar los puntos y escribe el nombre de cada parte. En el \u25B6 Player se juega de tres formas: revelar al tocar, arrastrar las palabras a su punto, o escribirlas.", premium:
|
|
4260
|
+
{ type: "labelit", emoji: "\u{1F3F7}\uFE0F", label: "Label It \u2014 etiquetar", blurb: "Sube cualquier l\xE1mina (c\xE9lula, flor, coraz\xF3n, mapa, una cara\u2026), toca la imagen para clavar los puntos y escribe el nombre de cada parte. En el \u25B6 Player se juega de tres formas: revelar al tocar, arrastrar las palabras a su punto, o escribirlas.", premium: true, ready: true },
|
|
4136
4261
|
// Ciclo (app-native, rama de CIENCIAS): una rueda de etapas reemplaza cinco slides sueltas.
|
|
4137
4262
|
// La rueda NO gira (gira el FOCO), así las imágenes quedan siempre derechas en la proyección.
|
|
4138
4263
|
// 🍕 «Fracciones manipulables» (regletas que se cortan) SE RETIRÓ del catálogo el 2026-08-12:
|
|
@@ -4140,21 +4265,26 @@ var WIDGETS = [
|
|
|
4140
4265
|
// armar sus láminas. El booster NO se borra —solo su tarjeta—: los slides que ya lo tengan
|
|
4141
4266
|
// puesto tienen que seguir abriendo y funcionando (su render, su editor y su config siguen
|
|
4142
4267
|
// cableados más abajo). Para devolverlo al catálogo, basta con volver a poner su entrada aquí.
|
|
4143
|
-
{ type: "cycle", emoji: "\u{1F504}", label: "Ciclo \u2014 rueda de etapas", blurb: "Rueda con las etapas de un proceso (metamorfosis, mitosis, ciclo del agua, fotos\xEDntesis\u2026), cada una con su imagen y su texto. En el \u25B6 Player, cada clic mueve el foco a la siguiente y el centro cuenta qu\xE9 pasa ah\xED. Puede cerrar en c\xEDrculo o ser una cadena, y los nombres pueden salir ocultos para que la clase adivine.", premium:
|
|
4268
|
+
{ type: "cycle", emoji: "\u{1F504}", label: "Ciclo \u2014 rueda de etapas", blurb: "Rueda con las etapas de un proceso (metamorfosis, mitosis, ciclo del agua, fotos\xEDntesis\u2026), cada una con su imagen y su texto. En el \u25B6 Player, cada clic mueve el foco a la siguiente y el centro cuenta qu\xE9 pasa ah\xED. Puede cerrar en c\xEDrculo o ser una cadena, y los nombres pueden salir ocultos para que la clase adivine.", premium: true, ready: true },
|
|
4269
|
+
// Show de Magia: «¿qué desapareció?» con teatro, sombrero y un botón mágico que elige el maestro.
|
|
4270
|
+
{ type: "magicshow", emoji: "\u{1F3A9}", label: "Show de Magia \u2014 \xBFqu\xE9 desapareci\xF3?", blurb: "Carga varias im\xE1genes (con su palabra o sin ella) y ponlas en un escenario de teatro. En el \u25B6 Player, cada clic en el bot\xF3n m\xE1gico mete las cartas al sombrero, las baraja y una desaparece; el siguiente clic la hace reaparecer. Nunca repite hasta que salgan todas. El bot\xF3n puede ser la varita o cualquier figura o animaci\xF3n de la biblioteca.", premium: true, ready: true },
|
|
4144
4271
|
// Graficador de fracciones (app-native, rama de MATEMÁTICA): HERRAMIENTA de autoría. No es una
|
|
4145
4272
|
// actividad para la clase: es la moneda con la que el maestro DIBUJA las fracciones de su
|
|
4146
4273
|
// lámina, una tras otra, sin salir a buscar imágenes fuera.
|
|
4147
|
-
{ type: "fracplot", emoji: "\u{1F355}", label: "Graficador de fracciones", blurb: "Escribe una fracci\xF3n en sus dos casillas \u2014abajo en cu\xE1ntos pedazos se corta, arriba cu\xE1ntos se pintan\u2014 y la dibuja al instante. Cada \xABInsertar\xBB la estampa en el slide como una figura que se mueve y se estira como cualquier imagen, y el panel se queda abierto para pedir la siguiente: 3/4, luego 2/3, luego 5/8\u2026 Pizza o barra, con la fracci\xF3n escrita debajo. Una impropia como 4/3 sale como dos pizzas.", premium:
|
|
4274
|
+
{ type: "fracplot", emoji: "\u{1F355}", label: "Graficador de fracciones", blurb: "Escribe una fracci\xF3n en sus dos casillas \u2014abajo en cu\xE1ntos pedazos se corta, arriba cu\xE1ntos se pintan\u2014 y la dibuja al instante. Cada \xABInsertar\xBB la estampa en el slide como una figura que se mueve y se estira como cualquier imagen, y el panel se queda abierto para pedir la siguiente: 3/4, luego 2/3, luego 5/8\u2026 Pizza o barra, con la fracci\xF3n escrita debajo. Una impropia como 4/3 sale como dos pizzas.", premium: true, ready: true },
|
|
4148
4275
|
// QR: el puente entre el slide proyectado y el celular del alumno. Estático a
|
|
4149
4276
|
// propósito (el enlace va grabado en el dibujo): cero infraestructura, cero costo
|
|
4150
4277
|
// mensual y sigue funcionando dentro del .zip exportado a LearnPress.
|
|
4151
4278
|
{ type: "qr", emoji: "\u{1F533}", label: "QR \u2014 el que se come el enlace", blurb: "Pon un enlace y sale un c\xF3digo QR que la clase escanea con el celular: la tarea, el formulario, un video, la sala de Booster Live. Al pulsar \xABGenerar\xBB, el c\xF3digo se COME el enlace: abre la boca y se traga a bocados unos c\xF3digos QR peque\xF1os; al cerrarla, los m\xF3dulos que faltaban caen en su sitio. Color, forma de los puntos y r\xF3tulo a tu gusto.", premium: false, ready: true },
|
|
4279
|
+
// Pizarra (app-native): escribir a mano, teclear y pegar imágenes DENTRO del slide. Para el
|
|
4280
|
+
// pizarrón táctil del aula y para la clase por Zoom. Lo de la clase no toca lo guardado.
|
|
4281
|
+
{ type: "pizarra", emoji: "\u{1F58D}\uFE0F", label: "Pizarra blanca", blurb: "Una moneda que en el \u25B6 Player abre una pizarra encima de todo el slide: escribe a mano con el dedo, el l\xE1piz o el rat\xF3n, teclea texto y pega im\xE1genes. Pluma, resaltador, borrador, colores y deshacer; la \u2715 roja la quita. Papel blanco, de rayas, cuadros, puntos, vidrio, transparente o pizarr\xF3n de tiza. Ideal para pizarras t\xE1ctiles y clases por Zoom: lo que se escribe en clase no toca tu planificaci\xF3n.", premium: false, ready: true },
|
|
4152
4282
|
{ type: "plotter", emoji: "\u{1F4C8}", label: "Graficador 2D \xB7 3D", blurb: "Orbe que abre un graficador de ecuaciones. En 2D: funciones, polares, param\xE9tricas, c\xEDrculos y elipses, inecuaciones, y los CORTES entre curvas marcados con sus coordenadas. En 3D: superficies z = f(x,y) que se giran con el dedo. Cada letra suelta (a, b, c) se vuelve un deslizador.", premium: true, ready: true }
|
|
4153
4283
|
];
|
|
4154
|
-
var APP_NATIVE_BOOSTERS = ["instructions", "magicwand", "magichat", "quitafondo", "transformar", "tombola", "trafficlight", "plotter", "labelit", "cycle", "fractions", "fracplot", "qr"];
|
|
4284
|
+
var APP_NATIVE_BOOSTERS = ["instructions", "magicwand", "magichat", "quitafondo", "transformar", "tombola", "trafficlight", "plotter", "labelit", "cycle", "magicshow", "fractions", "fracplot", "qr", "pizarra"];
|
|
4155
4285
|
var ARENAS_APP = [
|
|
4156
4286
|
{ type: "classification", emoji: "\u{1F6F8}", label: "Clasificaci\xF3n \xB7 Espacio", blurb: "Arrastra cada palabra a su categor\xEDa correcta. Tema espacial con OVNIs y aliens.", premium: false, ready: true },
|
|
4157
|
-
{ type: "classification-jungle", emoji: "\u{1F435}", label: "Clasificaci\xF3n \xB7 Selva", blurb: "Arrastra cada monito a su bungal\xF3 correcto. Tema selva con caba\xF1as de bamb\xFA.", premium:
|
|
4287
|
+
{ type: "classification-jungle", emoji: "\u{1F435}", label: "Clasificaci\xF3n \xB7 Selva", blurb: "Arrastra cada monito a su bungal\xF3 correcto. Tema selva con caba\xF1as de bamb\xFA.", premium: true, ready: true },
|
|
4158
4288
|
{ type: "gameshow", emoji: "\u{1F3AD}", label: "Game Show", blurb: "Concurso de 2 estudiantes: tel\xF3n, ruleta, preguntas con tiempo y podio.", premium: true, ready: true },
|
|
4159
4289
|
{ type: "race", emoji: "\u{1F3CE}\uFE0F", label: "Carrera de Karts", blurb: "Carrera estilo F\xF3rmula 1: hasta 4 jugadores eligen corredor, responden preguntas y ganan nitro.", premium: true, ready: true },
|
|
4160
4290
|
{ type: "pirate", emoji: "\u{1F3F4}\u200D\u2620\uFE0F", label: "Batalla Pirata", blurb: "Concurso de preguntas para TODA la clase desde sus celulares: gana oro por contestar r\xE1pido, y el que acierta abre un cofre que puede quitarle oro a un compa\xF1ero.", premium: true, ready: true },
|
|
@@ -4163,19 +4293,20 @@ var ARENAS_APP = [
|
|
|
4163
4293
|
{ type: "memory", emoji: "\u{1F0CF}", label: "Memory Game", blurb: "Encuentra las parejas ocultas. Cada carta acepta imagen, audio y texto: imagen\u2194imagen con audio, imagen\u2194palabra o palabra\u2194definici\xF3n. Individual o por equipos.", premium: false, ready: true },
|
|
4164
4294
|
// Word Storm nació en we-boosters y se trajo app-native (ver APP_NATIVE_ARENAS): su ficha
|
|
4165
4295
|
// ya no viene del puente, así que vive aquí como cualquier arena de la app.
|
|
4166
|
-
{ type: "booster-escape", emoji: "\u{1F9ED}", label: "Booster Escape", blurb: "Laberinto a contrarreloj con dos modos: Individual (corre por los pasillos, esquiva a los enemigos y llega a la respuesta correcta; 3 vidas) y Versus 1v1. Un toque y el explorador corre solo hasta topar. Edita las preguntas (con imagen/audio).", premium:
|
|
4167
|
-
{ type: "las-vegas", emoji: "\u{1F3B0}", label: "The Fabulous Las Vegas", blurb: "Tragamonedas para construir frases: jala la palanca, los rodillos frenan uno a uno y sale una combinaci\xF3n \u2014SHE \xB7 WRITTEN \xB7 A LETTER\u2014 que la clase dice en voz alta. De 1 a 4 rodillos, y cada casilla puede ser una palabra o un dibujo.", premium:
|
|
4296
|
+
{ type: "booster-escape", emoji: "\u{1F9ED}", label: "Booster Escape", blurb: "Laberinto a contrarreloj con dos modos: Individual (corre por los pasillos, esquiva a los enemigos y llega a la respuesta correcta; 3 vidas) y Versus 1v1. Un toque y el explorador corre solo hasta topar. Edita las preguntas (con imagen/audio).", premium: true, ready: true },
|
|
4297
|
+
{ type: "las-vegas", emoji: "\u{1F3B0}", label: "The Fabulous Las Vegas", blurb: "Tragamonedas para construir frases: jala la palanca, los rodillos frenan uno a uno y sale una combinaci\xF3n \u2014SHE \xB7 WRITTEN \xB7 A LETTER\u2014 que la clase dice en voz alta. De 1 a 4 rodillos, y cada casilla puede ser una palabra o un dibujo.", premium: true, ready: true },
|
|
4168
4298
|
// Sentence Line (app-native): la hermana de Word Factory — mismo motor, pero la pieza es una
|
|
4169
4299
|
// PALABRA y lo que se arma es una oración.
|
|
4170
|
-
{ type: "sentenceline", emoji: "\u{1F309}", label: "Sentence Bridge", blurb: "El puente de tablones: cada palabra es un tabl\xF3n y hay que ponerlas en orden para armar el puente. El explorador salta al tabl\xF3n que acabas de poner, y si la oraci\xF3n no es, los tablones equivocados se caen al r\xEDo. Cuando la frase queda bien, cruza al otro lado. Se parte sola por los espacios, o marcas trozos con barras \u2014\xABThe black cat / is sleeping / on the sofa\xBB\u2014 para ense\xF1ar la estructura.", premium:
|
|
4300
|
+
{ type: "sentenceline", emoji: "\u{1F309}", label: "Sentence Bridge", blurb: "El puente de tablones: cada palabra es un tabl\xF3n y hay que ponerlas en orden para armar el puente. El explorador salta al tabl\xF3n que acabas de poner, y si la oraci\xF3n no es, los tablones equivocados se caen al r\xEDo. Cuando la frase queda bien, cruza al otro lado. Se parte sola por los espacios, o marcas trozos con barras \u2014\xABThe black cat / is sleeping / on the sofa\xBB\u2014 para ense\xF1ar la estructura.", premium: true, ready: true },
|
|
4171
4301
|
// Word Factory (app-native): deletrear ordenando las letras que llegan por la cinta.
|
|
4172
|
-
{ type: "wordfactory", emoji: "\u{1F3ED}", label: "Word Factory", blurb: "Una f\xE1brica de palabras: entra un dibujo y sus letras llegan revueltas por la cinta transportadora. El alumno las mete en la m\xE1quina en el orden correcto y, cuando la palabra sale bien, la m\xE1quina fabrica el objeto y lo sella. Sirve para cualquier vocabulario: sube tu dibujo y escribe su palabra.", premium:
|
|
4302
|
+
{ type: "wordfactory", emoji: "\u{1F3ED}", label: "Word Factory", blurb: "Una f\xE1brica de palabras: entra un dibujo y sus letras llegan revueltas por la cinta transportadora. El alumno las mete en la m\xE1quina en el orden correcto y, cuando la palabra sale bien, la m\xE1quina fabrica el objeto y lo sella. Sirve para cualquier vocabulario: sube tu dibujo y escribe su palabra.", premium: true, ready: true },
|
|
4173
4303
|
// Mazo de Cartas (app-native): reemplaza la actividad de cartas de Wordwall. El maestro
|
|
4174
4304
|
// toca el mazo y sale una carta al azar; la descarta o la devuelve al mazo.
|
|
4175
4305
|
{ type: "deck", emoji: "\u{1F3B4}", label: "Mazo de Cartas", blurb: "Un mazo boca abajo: toca y sale una carta al azar, volte\xE1ndose en el aire. Cada carta lleva una palabra, una pregunta o un dibujo. La descartas o la devuelves al mazo, y con un bot\xF3n se mezcla otra vez. Para conversaci\xF3n, vocabulario, verbos o tarjetas de rol.", premium: false, ready: true },
|
|
4176
|
-
{ type: "word-storm", emoji: "\u2328\uFE0F", label: "Word Storm", blurb: "Lluvia de palabras: escr\xEDbelas con el teclado para destruirlas antes de que toquen la l\xEDnea de peligro. La dificultad sube solita. Edita la lista de palabras.", premium:
|
|
4306
|
+
{ type: "word-storm", emoji: "\u2328\uFE0F", label: "Word Storm", blurb: "Lluvia de palabras: escr\xEDbelas con el teclado para destruirlas antes de que toquen la l\xEDnea de peligro. La dificultad sube solita. Edita la lista de palabras.", premium: true, ready: true }
|
|
4177
4307
|
];
|
|
4178
|
-
var soloFicha = (e) => ({ type: e.type, emoji: e.emoji, label: e.label, blurb: e.blurb, premium: e.premium, ready: e.ready });
|
|
4308
|
+
var soloFicha = (e) => ({ type: e.type, emoji: e.emoji, label: e.label, blurb: e.blurb, premium: e.premium || PREMIUM_EXTERNAS.has(e.type), ready: e.ready });
|
|
4309
|
+
var PREMIUM_EXTERNAS = /* @__PURE__ */ new Set(["jenga", "we-spelling-jenga", "duck-shooting"]);
|
|
4179
4310
|
var ALL_WIDGETS = [
|
|
4180
4311
|
...WIDGETS,
|
|
4181
4312
|
...FICHAS_BOOSTERS.filter((e) => !APP_NATIVE_BOOSTERS.includes(e.type)).map(soloFicha)
|
|
@@ -4200,8 +4331,13 @@ function construirWidget(type, manos) {
|
|
|
4200
4331
|
guess: false,
|
|
4201
4332
|
sound: true,
|
|
4202
4333
|
stages: Array.from({ length: 4 }, () => ({ id: cycleUid(), text: "" }))
|
|
4203
|
-
} } : type === "
|
|
4204
|
-
|
|
4334
|
+
} } : type === "magicshow" ? { ext: {
|
|
4335
|
+
names: true,
|
|
4336
|
+
shuffle: true,
|
|
4337
|
+
sound: true,
|
|
4338
|
+
items: MAGICSHOW_DEMO.map((text) => ({ id: magicShowUid(), text }))
|
|
4339
|
+
} } : type === "fractions" ? { ext: { mode: "add", title: "", a: "1/2", b: "1/3", denoms: FRAC_DENOMS, labels: true, sound: true } } : type === "qr" ? { ext: { ...QR_DEFAULTS, gen: 0 } } : type === "pizarra" ? { ext: { ...PIZARRA_DEFAULTS, doc: pzVacio() } } : type === "wheel" ? { names: namesDefault } : type === "timer" ? { seconds: 60 } : type === "score" ? { teams: ["Equipo A", "Equipo B"] } : type === "groups" ? { names: namesDefault, teamCount: 2 } : type === "mystery" ? { items: ["apple", "happy", "run", "book", "blue", "friend", "school", "water"] } : { dice: 1 };
|
|
4340
|
+
const sz = ext ? ext.defaultSize : type === "trafficlight" ? { w: 16, h: 42 } : type === "labelit" ? { w: 52, h: 68 } : type === "cycle" ? { w: 66, h: 64 } : type === "magicshow" ? { w: 62, h: 62 } : type === "fractions" ? { w: 74, h: 50 } : type === "qr" ? { w: 16, h: 33 } : { w: 7, h: 12.4 };
|
|
4205
4341
|
const pos = sz.h > 55 ? { x: (100 - sz.w) / 2, y: (100 - sz.h) / 2 } : p;
|
|
4206
4342
|
return { ok: true, el: { id: manos.uid("wdg"), kind: "widget", widget: type, x: pos.x, y: pos.y, w: sz.w, h: sz.h, wcfg } };
|
|
4207
4343
|
}
|
package/package.json
CHANGED
package/proyectos.mjs
CHANGED
|
@@ -154,7 +154,12 @@ export async function resolverDestino(token, { equipo, carpeta }) {
|
|
|
154
154
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
155
155
|
const YO = `node ${process.argv[1]}`;
|
|
156
156
|
const [, , orden, ...resto] = process.argv;
|
|
157
|
-
const token = await tokenDeAcceso()
|
|
157
|
+
const token = await tokenDeAcceso().catch((e) => {
|
|
158
|
+
console.error(`
|
|
159
|
+
✕ ${e.message}
|
|
160
|
+
`);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
});
|
|
158
163
|
if (!token) {
|
|
159
164
|
console.error('\n ✕ No hay sesión. Entra primero con `sesion.mjs entrar`.\n');
|
|
160
165
|
process.exit(1);
|
package/servidor.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { join } from 'node:path';
|
|
|
30
30
|
import { existsSync } from 'node:fs';
|
|
31
31
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
32
32
|
import { createRequire } from 'node:module';
|
|
33
|
-
import { tokenDeAcceso } from './sesion.mjs';
|
|
33
|
+
import { alRenovar, tokenDeAcceso } from './sesion.mjs';
|
|
34
34
|
import { abrir, carpetas, crear, equipos, guardar, listar, resolverDestino } from './proyectos.mjs';
|
|
35
35
|
import { subirArchivo, subirBytes } from './subidas.mjs';
|
|
36
36
|
import { rematarEncargo } from './encargos.mjs';
|
|
@@ -104,6 +104,10 @@ const fabricas = () => ({
|
|
|
104
104
|
nuevaArena: (t) => nucleo.construirArena(t, manos()),
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
// Cada refresco de la sesión queda en el registro. Lo normal es ~1 por hora: si salen muchos
|
|
108
|
+
// seguidos, algo vuelve a renovar de más (ver `tokenDeAcceso()`).
|
|
109
|
+
alRenovar(({ refrescos, expira }) => aviso(`sesión renovada (#${refrescos}), vale hasta ${expira}`));
|
|
110
|
+
|
|
107
111
|
async function token() {
|
|
108
112
|
const t = await tokenDeAcceso();
|
|
109
113
|
if (!t) throw new Error('No hay sesión en este equipo. Corre `node mcp/sesion.mjs entrar` y vuelve a arrancar el agente.');
|
|
@@ -146,6 +150,10 @@ function herramientasDelLienzo() {
|
|
|
146
150
|
}));
|
|
147
151
|
}
|
|
148
152
|
|
|
153
|
+
const MAX_TANDA = 300;
|
|
154
|
+
// Lo que cambia de proyecto (dejaría el resto de la lista cayendo en otro) y la propia tanda.
|
|
155
|
+
const NO_EN_TANDA = new Set(['tanda', 'abrir_proyecto', 'crear_proyecto']);
|
|
156
|
+
|
|
149
157
|
const HERRAMIENTAS_PROYECTO = [
|
|
150
158
|
{
|
|
151
159
|
name: 'listar_proyectos',
|
|
@@ -156,13 +164,34 @@ const HERRAMIENTAS_PROYECTO = [
|
|
|
156
164
|
{
|
|
157
165
|
name: 'abrir_proyecto',
|
|
158
166
|
description: 'Abre un proyecto y lo deja LISTO para trabajar. A partir de aquí, todo lo que pongas '
|
|
159
|
-
+ 'cae en él. Hay que llamarlo antes que cualquier herramienta del lienzo.'
|
|
167
|
+
+ 'cae en él. Hay que llamarlo antes que cualquier herramienta del lienzo. Sin `slide`, se trabaja '
|
|
168
|
+
+ 'en el slide 1 de la escena 1; para CORREGIR un slide ya hecho, pásale `escena` y `slide` (o '
|
|
169
|
+
+ 'usa `ir_a_slide` después): entonces devuelve una línea por slide y el detalle SOLO de ese, que '
|
|
170
|
+
+ 'cuesta mucho menos leer que el proyecto entero.',
|
|
160
171
|
inputSchema: {
|
|
161
172
|
type: 'object',
|
|
162
|
-
properties: {
|
|
173
|
+
properties: {
|
|
174
|
+
id: { type: 'string', description: 'El id que devolvió `listar_proyectos`.' },
|
|
175
|
+
escena: { type: 'string', description: 'En qué escena trabajar: su número empezando en 1, o su nombre. Sin esto, la 1.' },
|
|
176
|
+
slide: { type: 'string', description: 'En qué slide trabajar: su número empezando en 1, su nombre o su id. Sin esto, el 1.' },
|
|
177
|
+
},
|
|
163
178
|
required: ['id'],
|
|
164
179
|
},
|
|
165
180
|
},
|
|
181
|
+
{
|
|
182
|
+
name: 'ir_a_slide',
|
|
183
|
+
description: 'Cambia el slide de TRABAJO del proyecto abierto a uno que ya existe, para corregirlo '
|
|
184
|
+
+ 'sin rehacer nada. Todo lo que pongas después (y el `capa` de las herramientas de poner) es de '
|
|
185
|
+
+ 'ese slide. Devuelve lo que tiene, con los ids de sus objetos y capas.',
|
|
186
|
+
inputSchema: {
|
|
187
|
+
type: 'object',
|
|
188
|
+
properties: {
|
|
189
|
+
escena: { type: 'string', description: 'Su número empezando en 1, o su nombre. Sin esto, la escena en la que estás.' },
|
|
190
|
+
slide: { type: 'string', description: 'Su número empezando en 1 dentro de esa escena, su nombre o su id.' },
|
|
191
|
+
},
|
|
192
|
+
required: ['slide'],
|
|
193
|
+
},
|
|
194
|
+
},
|
|
166
195
|
{
|
|
167
196
|
name: 'crear_proyecto',
|
|
168
197
|
description: 'Crea una presentación nueva, vacía, y la deja abierta para trabajar. Devuelve su id. '
|
|
@@ -285,6 +314,34 @@ const HERRAMIENTAS_PROYECTO = [
|
|
|
285
314
|
required: [],
|
|
286
315
|
},
|
|
287
316
|
},
|
|
317
|
+
{
|
|
318
|
+
name: 'tanda',
|
|
319
|
+
description: 'Hace VARIAS órdenes en una sola llamada, en orden, sobre el proyecto abierto. Es la forma '
|
|
320
|
+
+ 'BARATA de armar un slide: en vez de 100 llamadas sueltas (y 100 vueltas de pensar), una lista. '
|
|
321
|
+
+ 'Cada orden es { herramienta, args } con los mismos nombres y argumentos que la herramienta suelta; '
|
|
322
|
+
+ 'los `ref` de una orden valen para las siguientes. Se para en la primera que falle (lo de antes ya '
|
|
323
|
+
+ 'queda puesto) y te dice cuál y por qué. No sirve para abrir o crear proyectos. Guarda al final '
|
|
324
|
+
+ 'con `guardar_proyecto` (puede ser la última orden de la lista).',
|
|
325
|
+
inputSchema: {
|
|
326
|
+
type: 'object',
|
|
327
|
+
properties: {
|
|
328
|
+
ordenes: {
|
|
329
|
+
type: 'array',
|
|
330
|
+
description: `De 1 a ${MAX_TANDA} órdenes.`,
|
|
331
|
+
items: {
|
|
332
|
+
type: 'object',
|
|
333
|
+
properties: {
|
|
334
|
+
herramienta: { type: 'string', description: 'Nombre de la herramienta. Ej: "poner_texto".' },
|
|
335
|
+
args: { type: 'object', description: 'Sus argumentos, como si la llamaras sola.' },
|
|
336
|
+
},
|
|
337
|
+
required: ['herramienta'],
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
seguir_si_falla: { type: 'boolean', description: 'true = si una falla, sigue con las demás. Sin esto, se para.' },
|
|
341
|
+
},
|
|
342
|
+
required: ['ordenes'],
|
|
343
|
+
},
|
|
344
|
+
},
|
|
288
345
|
];
|
|
289
346
|
|
|
290
347
|
// ── Ejecutar una herramienta ─────────────────────────────────────────────────
|
|
@@ -336,6 +393,8 @@ function queHace(t, doc, slide) {
|
|
|
336
393
|
: a.type === 'goSlide' ? 'va a otro slide'
|
|
337
394
|
: a.type === 'closeLayer' ? 'cierra la capa'
|
|
338
395
|
: a.type === 'playSound' ? `suena ${a.targetId}`
|
|
396
|
+
: a.type === 'pauseSound' ? `pausa ${a.targetId || 'el audio'}`
|
|
397
|
+
: a.type === 'stopSound' ? `detiene ${a.targetId || 'el audio'}`
|
|
339
398
|
: a.type === 'speak' ? `lee «${corto(a.text, 24)}»`
|
|
340
399
|
: a.type === 'openUrl' ? `abre ${a.url}`
|
|
341
400
|
: a.type === 'hideEl' ? `oculta ${a.targetId || 'el objeto'}`
|
|
@@ -378,6 +437,78 @@ function resumenDelDoc(doc, { escena, slide } = {}) {
|
|
|
378
437
|
return filas.join('\n') || 'No hay nada con esos números de escena y slide.';
|
|
379
438
|
}
|
|
380
439
|
|
|
440
|
+
/**
|
|
441
|
+
* Varias órdenes en UNA llamada. El gasto de un agente no está en cada orden sino en cada vuelta
|
|
442
|
+
* de pensar: un slide eran ~150 llamadas y, en cada una, el modelo relee toda la conversación.
|
|
443
|
+
* Con una lista, el modelo piensa una vez y el servidor ejecuta. La respuesta es una línea por
|
|
444
|
+
* orden, sin la bitácora, para que tampoco la vuelta engorde.
|
|
445
|
+
*/
|
|
446
|
+
async function tanda(args) {
|
|
447
|
+
const ordenes = Array.isArray(args.ordenes) ? args.ordenes : [];
|
|
448
|
+
if (!ordenes.length) throw new Error('Falta `ordenes`: una lista de { herramienta, args }.');
|
|
449
|
+
if (ordenes.length > MAX_TANDA) throw new Error(`Son ${ordenes.length} órdenes y el máximo son ${MAX_TANDA}. Pártelas en varias tandas.`);
|
|
450
|
+
const filas = [];
|
|
451
|
+
let fallos = 0;
|
|
452
|
+
for (const [i, o] of ordenes.entries()) {
|
|
453
|
+
const h = String(o?.herramienta ?? '').trim();
|
|
454
|
+
try {
|
|
455
|
+
if (!h) throw new Error('falta `herramienta`.');
|
|
456
|
+
if (NO_EN_TANDA.has(h)) throw new Error(`\`${h}\` no va dentro de una tanda: llámala sola.`);
|
|
457
|
+
const dijo = await llamar(h, o.args && typeof o.args === 'object' ? o.args : {});
|
|
458
|
+
filas.push(`${i + 1} ✓ ${h} — ${String(dijo).split('\n(bitácora:')[0].split('\n')[0]}`);
|
|
459
|
+
} catch (e) {
|
|
460
|
+
fallos++;
|
|
461
|
+
filas.push(`${i + 1} ✕ ${h || '?'} — ${e.message}`);
|
|
462
|
+
if (!args.seguir_si_falla) {
|
|
463
|
+
if (i + 1 < ordenes.length) filas.push(`(paré aquí: las ${ordenes.length - i - 1} siguientes NO se hicieron)`);
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const texto = filas.join('\n');
|
|
469
|
+
if (fallos) throw new Error(`${fallos} orden(es) fallaron. Lo que salió bien ya está puesto (sin guardar).\n${texto}`);
|
|
470
|
+
return `${ordenes.length} orden(es) hechas.\n${texto}`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Una línea por slide: para orientarse sin leer el proyecto entero. */
|
|
474
|
+
function indiceDelDoc(doc) {
|
|
475
|
+
const filas = [];
|
|
476
|
+
doc.forEach((esc, i) => {
|
|
477
|
+
filas.push(`Escena ${i + 1}: «${esc.name ?? 'sin nombre'}»`);
|
|
478
|
+
esc.slides.forEach((s, j) => {
|
|
479
|
+
const n = s.base.length + s.layers.reduce((t, l) => t + l.els.length, 0);
|
|
480
|
+
filas.push(` slide ${j + 1}${s.name ? ` «${s.name}»` : ''} — ${n} objeto(s), ${s.layers.length} capa(s)`);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
return filas.join('\n');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Mueve el punto de trabajo a un slide que YA existe. Antes `abrir_proyecto` dejaba siempre el
|
|
488
|
+
* slide 1 y no había forma de ir a otro: corregir un slide hecho obligaba a rehacer el proyecto
|
|
489
|
+
* (Planificatron, hallazgo 23, 2026-10-12). Números desde 1, como los enseña `resumenDelDoc`.
|
|
490
|
+
*/
|
|
491
|
+
function irASlide(args) {
|
|
492
|
+
const b = yo.borrador;
|
|
493
|
+
const busca = (lista, pide, actual, qué) => {
|
|
494
|
+
if (pide === undefined || pide === null || String(pide).trim() === '') return actual;
|
|
495
|
+
const q = String(pide).trim();
|
|
496
|
+
const n = Number(q);
|
|
497
|
+
if (Number.isInteger(n) && n >= 1 && n <= lista.length) return n - 1;
|
|
498
|
+
const i = lista.findIndex((x) => x.id === q || String(x.name ?? '').trim().toLowerCase() === q.toLowerCase());
|
|
499
|
+
if (i < 0) {
|
|
500
|
+
throw new Error(`No hay ${qué === 'escena' ? 'ninguna escena' : 'ningún slide'} "${q}". `
|
|
501
|
+
+ `${qué === 'escena' ? 'Las' : 'Los'} que hay: ${lista.map((x, k) => `${k + 1} «${x.name ?? ''}»`).join(', ')}.`);
|
|
502
|
+
}
|
|
503
|
+
return i;
|
|
504
|
+
};
|
|
505
|
+
const e = busca(b.doc, args.escena, Math.min(b.escena, b.doc.length - 1), 'escena');
|
|
506
|
+
const s = busca(b.doc[e].slides, args.slide, 0, 'slide');
|
|
507
|
+
b.escena = e;
|
|
508
|
+
b.slide = s;
|
|
509
|
+
return { escena: e + 1, slide: s + 1 };
|
|
510
|
+
}
|
|
511
|
+
|
|
381
512
|
async function llamar(nombre, args = {}) {
|
|
382
513
|
// ── Proyectos ──────────────────────────────────────────────────────────────
|
|
383
514
|
if (nombre === 'listar_proyectos') {
|
|
@@ -410,6 +541,8 @@ async function llamar(nombre, args = {}) {
|
|
|
410
541
|
yo.proyecto = p;
|
|
411
542
|
yo.borrador = nucleo.nuevoBorrador(Array.isArray(p.document) ? p.document : [], 0, 0, '');
|
|
412
543
|
yo.borrador.estricto = true; // sin nadie delante: lo que el Robot resuelve solo, aquí se niega
|
|
544
|
+
const aUnSlide = nombre === 'abrir_proyecto' && (args.slide !== undefined || args.escena !== undefined);
|
|
545
|
+
const dondeTrabajo = aUnSlide ? irASlide({ escena: args.escena, slide: args.slide ?? 1 }) : null;
|
|
413
546
|
yo.borrador.premium = yo.plan === 'pro';
|
|
414
547
|
// `poner_dibujo`, `poner_fondo` y `poner_lottie` buscan en caliente.
|
|
415
548
|
await Promise.all([nucleo.cargarBiblioteca(), nucleo.cargarAnimaciones()]);
|
|
@@ -422,7 +555,23 @@ async function llamar(nombre, args = {}) {
|
|
|
422
555
|
: 'tu espacio Personal';
|
|
423
556
|
return `${nombre === 'crear_proyecto' ? 'Creado' : 'Abierto'} «${p.title}»\nid: ${p.id}\n`
|
|
424
557
|
+ `Dónde: ${nombre === 'crear_proyecto' ? donde : (p.team_id ? `equipo ${p.team_id}` : 'tu espacio Personal')} — se abre desde la galería de https://boosterarena.com\n`
|
|
425
|
-
+ `Plan de la cuenta: ${yo.plan}. ${yo.borrador.doc.length} escena(s).\n
|
|
558
|
+
+ `Plan de la cuenta: ${yo.plan}. ${yo.borrador.doc.length} escena(s).\n`
|
|
559
|
+
+ `Trabajando en: escena ${yo.borrador.escena + 1}, slide ${yo.borrador.slide + 1}.\n\n`
|
|
560
|
+
// Abierto en un slide: el índice de todo + el detalle SOLO de ese. Un proyecto entero con sus
|
|
561
|
+
// ids son miles de tokens que, para corregir un slide, no hacen falta.
|
|
562
|
+
+ (dondeTrabajo
|
|
563
|
+
? `${indiceDelDoc(yo.borrador.doc)}\n\nEl slide de trabajo:\n${resumenDelDoc(yo.borrador.doc, dondeTrabajo)}`
|
|
564
|
+
: resumenDelDoc(yo.borrador.doc));
|
|
565
|
+
}
|
|
566
|
+
if (nombre === 'tanda') {
|
|
567
|
+
exigeProyecto();
|
|
568
|
+
return tanda(args);
|
|
569
|
+
}
|
|
570
|
+
if (nombre === 'ir_a_slide') {
|
|
571
|
+
exigeProyecto();
|
|
572
|
+
const { escena, slide } = irASlide(args);
|
|
573
|
+
return `Ahora trabajas en la escena ${escena}, slide ${slide}. Lo que pongas va aquí; guarda al terminar.\n\n`
|
|
574
|
+
+ resumenDelDoc(yo.borrador.doc, { escena, slide });
|
|
426
575
|
}
|
|
427
576
|
// La voz de Booster: la MISMA función que usa el editor (`/api/voz`, que pone el candado Pro y
|
|
428
577
|
// descuenta las letras en el servidor), luego se sube el MP3 y se planta como un audio normal.
|
|
@@ -522,7 +671,14 @@ ${r.aviso}` : '');
|
|
|
522
671
|
// ── El lienzo: lo hace el MISMO ejecutor que el Robot del navegador ─────────
|
|
523
672
|
exigeProyecto();
|
|
524
673
|
yo.borrador.fallo = false;
|
|
525
|
-
|
|
674
|
+
// Lo que pidió la persona vale SOLO para esta orden: si se quedara puesto, un «quita esto» de
|
|
675
|
+
// hace diez órdenes abriría el cerrojo para cualquier `borrar` posterior.
|
|
676
|
+
yo.borrador.pedido = String(args.pedido_del_maestro ?? '');
|
|
677
|
+
let dijo;
|
|
678
|
+
try { dijo = nucleo.ejecutar(yo.borrador, nombre, args, fabricas()); } finally { yo.borrador.pedido = ''; }
|
|
679
|
+
if (yo.borrador.fallo && !args.pedido_del_maestro && (nombre === 'borrar' || args.accion === 'quitar')) {
|
|
680
|
+
throw new Error(`${dijo} Si la persona SÍ te lo pidió, vuelve a llamar pasando su mensaje tal cual en \`pedido_del_maestro\`.`);
|
|
681
|
+
}
|
|
526
682
|
// ⚠️ UN RECHAZO ES UN ERROR, NO UN ÉXITO CON MALAS NOTICIAS. El ejecutor contesta con una frase
|
|
527
683
|
// también cuando se niega («No existe ninguna arena…»), porque al Robot del navegador le basta
|
|
528
684
|
// leerla. A una fábrica sin nadie delante, no: la contaba como hecha y el slide quedaba vacío
|
package/sesion.mjs
CHANGED
|
@@ -73,12 +73,16 @@ function cliente() {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
// ── La credencial en disco ───────────────────────────────────────────────────
|
|
76
|
-
// Se guarda el `refresh_token
|
|
77
|
-
//
|
|
76
|
+
// Se guarda el `refresh_token` y, junto a él, el token de acceso con su caducidad. Este archivo
|
|
77
|
+
// ES una llave, así que va a 600 (solo su dueño), igual que `~/.claude`.
|
|
78
|
+
// El de acceso va también al archivo para que dos procesos (el servidor y un `quien` suelto, o
|
|
79
|
+
// dos agentes) se lo PRESTEN en vez de renovar cada uno por su lado.
|
|
78
80
|
function guardar(sesion) {
|
|
79
81
|
if (!existsSync(CARPETA)) mkdirSync(CARPETA, { recursive: true, mode: 0o700 });
|
|
80
82
|
const datos = {
|
|
81
83
|
refresh_token: sesion.refresh_token,
|
|
84
|
+
access_token: sesion.access_token,
|
|
85
|
+
expira: sesion.expires_at ?? Math.floor(Date.now() / 1000) + (sesion.expires_in ?? 3600),
|
|
82
86
|
correo: sesion.user?.email ?? null,
|
|
83
87
|
guardado: new Date().toISOString(),
|
|
84
88
|
};
|
|
@@ -91,15 +95,91 @@ export function leerCredencial() {
|
|
|
91
95
|
try { return JSON.parse(readFileSync(CREDENCIAL, 'utf8')); } catch { return null; }
|
|
92
96
|
}
|
|
93
97
|
|
|
94
|
-
|
|
98
|
+
// ── El token de acceso: se RENUEVA SOLO CUANDO VA A CADUCAR ───────────────────
|
|
99
|
+
// ⚠️ Antes se llamaba a `refreshSession` en CADA orden. Un slide con capas son ~150 órdenes, y
|
|
100
|
+
// Supabase limita los refrescos por IP: a mitad de tanda contestaba con error, este código se lo
|
|
101
|
+
// tragaba (`return null`) y el agente veía «No hay sesión» / «ya no vale». Minutos después volvía
|
|
102
|
+
// solo. Lo destapó el agente de una coach fabricando boosters (Planificatron, hallazgo 22,
|
|
103
|
+
// 2026-10-12). Ahora:
|
|
104
|
+
// · el token vive en memoria (y en el archivo) y se reutiliza mientras le queden > 5 minutos;
|
|
105
|
+
// · UN solo refresco a la vez: las órdenes que llegan mientras tanto esperan al mismo;
|
|
106
|
+
// · si el refresco falla, se dice el error REAL de Supabase, no «no hay sesión».
|
|
107
|
+
const MARGEN_S = 5 * 60;
|
|
108
|
+
let enMemoria = null; // { access_token, expira, refresh_token }
|
|
109
|
+
let enCurso = null; // la promesa del refresco que está en marcha, si hay uno
|
|
110
|
+
let refrescos = 0;
|
|
111
|
+
let alRenovarFn = null;
|
|
112
|
+
|
|
113
|
+
/** Para que el servidor apunte en su bitácora cada refresco (debería ser ~1 por hora). */
|
|
114
|
+
export function alRenovar(fn) { alRenovarFn = fn; }
|
|
115
|
+
|
|
116
|
+
const ahoraS = () => Date.now() / 1000;
|
|
117
|
+
const vale = (c) => !!c?.access_token && Number(c.expira) - ahoraS() > MARGEN_S;
|
|
118
|
+
const esperar = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
119
|
+
|
|
120
|
+
function describir(error) {
|
|
121
|
+
if (!error) return 'Supabase no devolvió sesión';
|
|
122
|
+
const partes = [error.message || String(error)];
|
|
123
|
+
if (error.code) partes.push(`[${error.code}]`);
|
|
124
|
+
if (error.status) partes.push(`(HTTP ${error.status})`);
|
|
125
|
+
return partes.join(' ');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function renovar(cred) {
|
|
129
|
+
let ultimo = null;
|
|
130
|
+
let refresh = cred.refresh_token;
|
|
131
|
+
for (let intento = 0; intento < 3; intento++) {
|
|
132
|
+
const { data, error } = await cliente().auth.refreshSession({ refresh_token: refresh });
|
|
133
|
+
if (!error && data?.session) {
|
|
134
|
+
guardar(data.session); // el refresh token ROTA: si no se reescribe, la próxima vez falla
|
|
135
|
+
enMemoria = {
|
|
136
|
+
access_token: data.session.access_token,
|
|
137
|
+
expira: data.session.expires_at,
|
|
138
|
+
refresh_token: data.session.refresh_token,
|
|
139
|
+
};
|
|
140
|
+
refrescos++;
|
|
141
|
+
alRenovarFn?.({ refrescos, expira: new Date(enMemoria.expira * 1000).toISOString() });
|
|
142
|
+
return enMemoria.access_token;
|
|
143
|
+
}
|
|
144
|
+
ultimo = error;
|
|
145
|
+
// ¿Lo renovó otro proceso mientras tanto? Entonces el archivo ya trae uno bueno.
|
|
146
|
+
const otra = leerCredencial();
|
|
147
|
+
if (otra?.refresh_token && otra.refresh_token !== refresh) {
|
|
148
|
+
if (vale(otra)) {
|
|
149
|
+
enMemoria = { access_token: otra.access_token, expira: otra.expira, refresh_token: otra.refresh_token };
|
|
150
|
+
return otra.access_token;
|
|
151
|
+
}
|
|
152
|
+
refresh = otra.refresh_token;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// Un 4xx que no es límite de peticiones es definitivo (sesión cerrada, token revocado):
|
|
156
|
+
// reintentar no lo arregla. Red caída, 429 y 5xx sí merecen otra vuelta.
|
|
157
|
+
const st = error?.status;
|
|
158
|
+
if (st && st >= 400 && st < 500 && st !== 429) break;
|
|
159
|
+
await esperar(1000 * 2 ** intento);
|
|
160
|
+
}
|
|
161
|
+
// Si el token que ya tenemos aún no ha caducado del todo, mejor usarlo que tumbar la orden.
|
|
162
|
+
if (cred.access_token && Number(cred.expira) - ahoraS() > 30) return cred.access_token;
|
|
163
|
+
throw new Error(`La sesión de ${cred.correo ?? 'este equipo'} no se pudo renovar: ${describir(ultimo)}. `
|
|
164
|
+
+ 'Si persiste, vuelve a entrar con `npx boosterarena-mcp entrar`.');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Devuelve un token de acceso válido, o null si en este equipo no hay sesión.
|
|
169
|
+
* Si hay sesión pero Supabase no la renueva, LANZA con el error real.
|
|
170
|
+
*/
|
|
95
171
|
export async function tokenDeAcceso() {
|
|
96
172
|
const cred = leerCredencial();
|
|
97
|
-
if (!cred?.refresh_token) return null;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
|
|
173
|
+
if (!cred?.refresh_token) { enMemoria = null; return null; }
|
|
174
|
+
// Otra entrada (u otro proceso que renovó) cambió el archivo: lo de memoria ya no es lo vigente.
|
|
175
|
+
if (enMemoria && enMemoria.refresh_token !== cred.refresh_token) enMemoria = null;
|
|
176
|
+
if (vale(enMemoria)) return enMemoria.access_token;
|
|
177
|
+
if (vale(cred)) {
|
|
178
|
+
enMemoria = { access_token: cred.access_token, expira: Number(cred.expira), refresh_token: cred.refresh_token };
|
|
179
|
+
return cred.access_token;
|
|
180
|
+
}
|
|
181
|
+
enCurso ??= renovar(cred).finally(() => { enCurso = null; });
|
|
182
|
+
return enCurso;
|
|
103
183
|
}
|
|
104
184
|
|
|
105
185
|
// ── Abrir el navegador ───────────────────────────────────────────────────────
|
|
@@ -246,7 +326,8 @@ function escuchar(puerto, estado) {
|
|
|
246
326
|
export async function quien() {
|
|
247
327
|
const cred = leerCredencial();
|
|
248
328
|
if (!cred) { console.log(` No hay sesión en este equipo. Usa: ${YO} entrar`); return; }
|
|
249
|
-
|
|
329
|
+
let token;
|
|
330
|
+
try { token = await tokenDeAcceso(); } catch (e) { console.log(` ✕ ${e.message}`); return; }
|
|
250
331
|
if (!token) { console.log(` La sesión de ${cred.correo ?? 'este equipo'} ya no vale. Vuelve a entrar.`); return; }
|
|
251
332
|
const r = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
|
252
333
|
headers: { apikey: SUPABASE_KEY, Authorization: `Bearer ${token}` },
|