diff --git a/.env.sample b/.env.sample index c76f400..696b650 100644 --- a/.env.sample +++ b/.env.sample @@ -26,3 +26,21 @@ MAX_WS_NEW_PER_SEC=50 # Viewer vote broadcast debounce in ms (default: 250) VIEWER_VOTE_BROADCAST_DEBOUNCE_MS=250 + +# Auto-pause: seconds of inactivity before pausing when no viewers are connected (default: 60000 ms) +AUTOPAUSE_DELAY_MS=60000 + +# ── Redsys (optional — enables paid user question submissions at /pregunta) ──── + +# Public base URL of this server (used for Redsys redirect/notification URLs) +# Example: https://argument.es +PUBLIC_URL=https://argument.es + +# Redsys merchant credentials (get these from your bank / Redsys portal) +REDSYS_MERCHANT_CODE= +REDSYS_TERMINAL=1 +# Base64-encoded Redsys secret key (SHA-256 key from the merchant portal) +REDSYS_SECRET_KEY= + +# Set to "false" to use the live Redsys gateway (default: test environment) +REDSYS_TEST=true diff --git a/README.md b/README.md index 218191f..f2c641e 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,12 @@ La base de datos SQLite se persiste en un volumen Docker (`argumentes_data`). | `ADMIN_SECRET` | ✅ | — | Contraseña del panel de administración | | `PORT` | | `5109` | Puerto del servidor | | `DATABASE_PATH` | | `argumentes.sqlite` | Ruta al archivo SQLite | +| `AUTOPAUSE_DELAY_MS` | | `60000` | ms sin espectadores antes de autopausar | +| `PUBLIC_URL` | | — | URL pública (necesaria para Redsys) | +| `REDSYS_MERCHANT_CODE` | | — | Código de comercio Redsys | +| `REDSYS_TERMINAL` | | `1` | Terminal Redsys | +| `REDSYS_SECRET_KEY` | | — | Clave secreta Redsys (Base64) | +| `REDSYS_TEST` | | `true` | `false` para usar el entorno de producción | Ver `.env.sample` para todas las opciones. @@ -55,6 +61,14 @@ Disponible en `/admin`. Funcionalidades: - **Borrar** todos los datos (requiere confirmación) - **Estado** del servidor en tiempo real +## Autopausado + +El juego se pausa automáticamente si no hay espectadores conectados durante más de `AUTOPAUSE_DELAY_MS` ms (por defecto 60 segundos). En cuanto se conecta un espectador, el juego se reanuda solo. El panel de administración muestra "Esperando espectadores…" en este estado. + +## Preguntas del público (`/pregunta`) + +Los espectadores pueden pagar 1€ a través de Redsys para proponer una pregunta de completar-la-frase. La pregunta se usa en el siguiente sorteo en lugar de generarla con IA. Requiere configurar las variables `REDSYS_*` en `.env`. + ## Cómo funcionan las preguntas El array `ALL_PROMPTS` en `prompts.ts` sirve únicamente como **guía de estilo**. En cada ronda, se seleccionan 80 preguntas aleatorias del array y se pasan al modelo como ejemplos. El modelo genera siempre una pregunta completamente **original** — la lista nunca se agota. diff --git a/broadcast.ts b/broadcast.ts index e3d8dd5..f0f435d 100644 --- a/broadcast.ts +++ b/broadcast.ts @@ -35,6 +35,7 @@ type GameState = { viewerScores: Record; done: boolean; isPaused: boolean; + autoPaused?: boolean; generation: number; }; type StateMessage = { diff --git a/db.ts b/db.ts index 73c708d..fe88607 100644 --- a/db.ts +++ b/db.ts @@ -41,3 +41,36 @@ export function clearAllRounds() { db.exec("DELETE FROM rounds;"); db.exec("DELETE FROM sqlite_sequence WHERE name = 'rounds';"); } + +// ── Questions (user-submitted via Redsys) ─────────────────────────────────── + +db.exec(` + CREATE TABLE IF NOT EXISTS questions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + order_id TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + +export function createPendingQuestion(text: string, orderId: string): number { + const stmt = db.prepare("INSERT INTO questions (text, order_id) VALUES ($text, $orderId)"); + const result = stmt.run({ $text: text, $orderId: orderId }); + return result.lastInsertRowid as number; +} + +export function markQuestionPaid(orderId: string): boolean { + const stmt = db.prepare("UPDATE questions SET status = 'paid' WHERE order_id = $orderId AND status = 'pending'"); + const result = stmt.run({ $orderId: orderId }); + return result.changes > 0; +} + +export function getNextPendingQuestion(): { id: number; text: string; order_id: string } | null { + return db.query("SELECT id, text, order_id FROM questions WHERE status = 'paid' ORDER BY id ASC LIMIT 1") + .get() as { id: number; text: string; order_id: string } | null; +} + +export function markQuestionUsed(id: number): void { + db.prepare("UPDATE questions SET status = 'used' WHERE id = $id").run({ $id: id }); +} diff --git a/frontend.css b/frontend.css index eb8d7fc..c6021b3 100644 --- a/frontend.css +++ b/frontend.css @@ -736,6 +736,28 @@ body { ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +/* ── Site Footer ──────────────────────────────────────────────── */ + +.site-footer { + flex-shrink: 0; + padding: 14px 20px; + border-top: 1px solid var(--border); + text-align: center; + font-size: 12px; + color: var(--text-muted); + line-height: 1.7; +} + +.site-footer__link { + color: var(--text-dim); + text-decoration: none; +} + +.site-footer__link:hover { + color: var(--text); + text-decoration: underline; +} + /* ── Desktop (1024px+) ───────────────────────────────────────── */ @media (min-width: 1024px) { @@ -791,4 +813,6 @@ body { padding: 24px; gap: 24px; } + + .site-footer { display: none; } } diff --git a/frontend.tsx b/frontend.tsx index bdfe4c4..24dca83 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -41,6 +41,7 @@ type GameState = { viewerScores: Record; done: boolean; isPaused: boolean; + autoPaused?: boolean; generation: number; }; type StateMessage = { @@ -616,7 +617,7 @@ function App() { className="viewer-pill" style={{ color: "var(--text-muted)", borderColor: "var(--border)" }} > - En pausa + {state.autoPaused ? "Esperando espectadores…" : "En pausa"} )}
@@ -654,6 +655,21 @@ function App() {
+
+

IAs compiten respondiendo preguntas absurdas — los jueces votan, tú también puedes.

+

+ por{" "} + + Cloud Host + + {" "}— La web simplificada, la nube gestionada +

+
); } diff --git a/game.ts b/game.ts index aca66d0..0a1505f 100644 --- a/game.ts +++ b/game.ts @@ -76,6 +76,7 @@ export type GameState = { viewerScores: Record; done: boolean; isPaused: boolean; + autoPaused: boolean; generation: number; }; @@ -264,7 +265,7 @@ export async function callVote( return cleaned.startsWith("A") ? "A" : "B"; } -import { saveRound } from "./db.ts"; +import { saveRound, getNextPendingQuestion, markQuestionUsed } from "./db.ts"; // ── Game loop ─────────────────────────────────────────────────────────────── @@ -325,12 +326,21 @@ export async function runGame( // ── Prompt phase ── try { - const prompt = await withRetry( - () => callGeneratePrompt(prompter), - (s) => isRealString(s, 10), - 3, - `R${r}:prompt:${prompter.name}`, - ); + // Use a user-submitted question if one is pending, otherwise call AI + const pendingQ = getNextPendingQuestion(); + let prompt: string; + if (pendingQ) { + markQuestionUsed(pendingQ.id); + prompt = pendingQ.text; + log("INFO", `R${r}:prompt`, "Using user-submitted question", { id: pendingQ.id }); + } else { + prompt = await withRetry( + () => callGeneratePrompt(prompter), + (s) => isRealString(s, 10), + 3, + `R${r}:prompt:${prompter.name}`, + ); + } if (state.generation !== roundGeneration) { continue; } diff --git a/pregunta.css b/pregunta.css new file mode 100644 index 0000000..f063064 --- /dev/null +++ b/pregunta.css @@ -0,0 +1,187 @@ +/* ── Reset & Variables ────────────────────────────────────────── */ + +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #0a0a0a; + --surface: #111; + --border: #1c1c1c; + --text: #ededed; + --text-dim: #888; + --text-muted: #444; + --accent: #D97757; + --sans: 'Inter', -apple-system, sans-serif; + --mono: 'JetBrains Mono', 'SF Mono', monospace; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + min-height: 100vh; + min-height: 100dvh; +} + +/* ── Layout ────────────────────────────────────────────────────── */ + +.pregunta { + min-height: 100vh; + min-height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + padding: 32px 20px; +} + +.pregunta__panel { + width: 100%; + max-width: 480px; + display: flex; + flex-direction: column; + gap: 20px; +} + +/* ── Logo ──────────────────────────────────────────────────────── */ + +.pregunta__logo { + display: inline-flex; + text-decoration: none; + margin-bottom: 4px; +} + +.pregunta__logo img { + height: 20px; + width: auto; +} + +/* ── Typography ────────────────────────────────────────────────── */ + +.pregunta__panel h1 { + font-size: 22px; + font-weight: 700; + line-height: 1.3; +} + +.pregunta__sub { + color: var(--text-dim); + font-size: 14px; +} + +/* ── Form ──────────────────────────────────────────────────────── */ + +.pregunta__form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.pregunta__label { + font-size: 13px; + font-weight: 500; + color: var(--text-dim); +} + +.pregunta__textarea { + width: 100%; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + color: var(--text); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; + resize: vertical; + min-height: 88px; + transition: border-color 0.15s; +} + +.pregunta__textarea:focus { + outline: none; + border-color: #444; +} + +.pregunta__textarea::placeholder { + color: var(--text-muted); +} + +.pregunta__hint { + font-size: 12px; + color: var(--text-muted); + font-family: var(--mono); +} + +/* ── Error ─────────────────────────────────────────────────────── */ + +.pregunta__error { + background: rgba(220, 60, 60, 0.08); + border: 1px solid rgba(220, 60, 60, 0.25); + border-radius: 6px; + padding: 10px 14px; + color: #ff6b6b; + font-size: 13px; +} + +/* ── Submit ────────────────────────────────────────────────────── */ + +.pregunta__submit { + padding: 12px 20px; + background: var(--accent); + border: none; + border-radius: 8px; + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + font-family: var(--sans); + transition: opacity 0.15s; +} + +.pregunta__submit:hover:not(:disabled) { + opacity: 0.85; +} + +.pregunta__submit:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +/* ── Status button (ok/ko states) ──────────────────────────────── */ + +.pregunta__btn { + display: inline-block; + padding: 12px 20px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 14px; + text-decoration: none; + text-align: center; + transition: border-color 0.15s; +} + +.pregunta__btn:hover { + border-color: #444; +} + +/* ── Quick links ───────────────────────────────────────────────── */ + +.pregunta__links { + display: flex; + gap: 16px; + justify-content: center; +} + +.pregunta__links a { + color: var(--text-muted); + text-decoration: none; + font-size: 13px; +} + +.pregunta__links a:hover { + color: var(--text-dim); +} diff --git a/pregunta.html b/pregunta.html new file mode 100644 index 0000000..f39319d --- /dev/null +++ b/pregunta.html @@ -0,0 +1,20 @@ + + + + + + Propón una pregunta — argument.es + + + + + + + +
+ + + diff --git a/pregunta.tsx b/pregunta.tsx new file mode 100644 index 0000000..a872bac --- /dev/null +++ b/pregunta.tsx @@ -0,0 +1,145 @@ +import React, { useState } from "react"; +import { createRoot } from "react-dom/client"; +import "./pregunta.css"; + +function App() { + const params = new URLSearchParams(window.location.search); + const isOk = params.get("ok") === "1"; + const isKo = params.get("ko") === "1"; + + const [text, setText] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + if (isOk) { + return ( +
+
+ + argument.es + +

¡Pregunta enviada!

+

+ Tu pregunta se usará en el próximo sorteo entre las IAs. ¡Gracias por participar! +

+ Volver al juego +
+
+ ); + } + + if (isKo) { + return ( +
+
+ + argument.es + +

Pago cancelado

+

+ El pago no se completó. Tu pregunta no ha sido guardada. +

+ Intentar de nuevo + +
+
+ ); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + const res = await fetch("/api/pregunta/iniciar", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: text.trim() }), + }); + if (!res.ok) { + const msg = await res.text(); + throw new Error(msg || `Error ${res.status}`); + } + const data = (await res.json()) as { + tpvUrl: string; + merchantParams: string; + signature: string; + signatureVersion: string; + }; + + // Build and auto-submit the Redsys payment form + const form = document.createElement("form"); + form.method = "POST"; + form.action = data.tpvUrl; + for (const [name, value] of Object.entries({ + Ds_SignatureVersion: data.signatureVersion, + Ds_MerchantParameters: data.merchantParams, + Ds_Signature: data.signature, + })) { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = name; + input.value = value; + form.appendChild(input); + } + document.body.appendChild(form); + form.submit(); + } catch (err) { + setError(err instanceof Error ? err.message : "Error al procesar la solicitud"); + setSubmitting(false); + } + } + + return ( +
+
+ + argument.es + +

Propón una pregunta

+

+ Paga 1€ y tu pregunta de completar-la-frase se usará en el próximo sorteo entre las IAs. +

+ +
+ +