Compare commits

...

20 Commits

Author SHA1 Message Date
40c919fc64 feat: add viewer voting on user answers with leaderboard scoring
Viewers can now vote for their favourite audience answers during the
30-second voting window. Votes are persisted to the DB at round end
and aggregated as SUM(votes) in the JUGADORES leaderboard.

- db.ts: add persistUserAnswerVotes(); switch getPlayerScores() to SUM(votes)
- game.ts: add userAnswerVotes to RoundState; persist votes before saveRound
- server.ts: add userAnswerVoters map + /api/vote/respuesta endpoint
- frontend.tsx: add userAnswerVotes type; vote state/handler in App; ▲ buttons in Arena
- frontend.css: flex layout for user-answer rows; user-vote-btn styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 18:26:09 +01:00
fe5bb5a5c2 feat: admin can answer questions without paying for testing
- Server: /api/respuesta/enviar checks admin cookie; if authorized,
  bypasses credit check and stores answer via insertAdminAnswer()
- DB: insertAdminAnswer() inserts directly into user_answers with
  username='Admin', skipping the credit budget entirely
- Frontend: ProposeAnswer checks /api/admin/status on mount; if admin
  is logged in, shows the answer form directly (orange Admin badge)
  instead of the payment tier selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 18:15:46 +01:00
f9a8e2544f feat: users answer alongside AI instead of proposing questions
- Core mechanic change: users now submit answers to the live prompt,
  competing alongside AI models; answers broadcast to all viewers
- New pricing (no time limit, pure count-based, refillable):
  0,99€ = 10 resp · 9,99€ = 300 resp · 19,99€ = 1000 resp
- DB: new user_answers table; submitUserAnswer() atomically validates
  credit, inserts answer, decrements budget; JUGADORES leaderboard
  now scores by user_answers count
- server: /api/respuesta/enviar endpoint; credit activation sets
  expires_at 10 years out (effectively no expiry); answers injected
  into live round state and broadcast via WebSocket
- frontend: ProposeAnswer widget shows current prompt, textarea active
  during answering phase, tracks per-round submission state;
  Arena shows Respuestas del público section live

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 12:09:53 +01:00
3e5c080466 feat: embed propose-question widget inline in main game page
- Move /pregunta flow into a compact ProposeQuestion component
  rendered inside <main> above the footer
- Tier cards, username input, Redsys payment, polling, and question
  submission all work inline without leaving the game page
- Payment redirects now go to /?credito_ok=ORDER and /?ko=1
  so the game stays in view during the full payment cycle
- Badge shows live questions remaining; updates on each submission
- Removed /pregunta link from footer (functionality is now inline)
- .propose-* CSS: compact tier grid, textarea+button row, spinner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 17:21:35 +01:00
d42e93b013 feat: question-count tiers, footer in main, pregunta footer
- Pricing: 0,99€/10q · 9,99€/200q · 19,99€/unlimited (all 30 days)
- DB: max_questions + questions_used columns on credits table;
  consumeCreditQuestion() atomically validates, creates question,
  and decrements quota in a single transaction
- Server: updated CREDIT_TIERS, /api/credito/estado returns questionsLeft,
  /api/pregunta/enviar returns updated questionsLeft after each submission
- pregunta.tsx: badge shows live question count; submit disabled when exhausted;
  questionsLeft synced to localStorage after each submission; Cloud Host footer added
- footer: moved from Standings sidebar into <main> (scrolls with game content);
  also added to all pregunta page states

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 17:13:02 +01:00
e772fb5cc0 feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
  - credits table with token, tier, expires_at, status lifecycle
  - /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
  - Polling-based token delivery to browser after Redsys URLOK redirect
  - localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
  - /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
2fac92356d feat: footer, auto-pause on no viewers, and Redsys question submissions
- Footer: add site-footer with one-liner + Cloud Host branding linking to cloudhost.es (mobile only, hidden on desktop)
- Auto-pause: game pauses automatically after AUTOPAUSE_DELAY_MS (default 60s) with no viewers connected; auto-resumes when first viewer connects; shows "Esperando espectadores…" in the UI
- Redsys: new /pregunta page lets viewers pay 1€ via Redsys to submit a fill-in question; submitted questions are used as prompts in the next round instead of AI generation; new redsys.ts implements HMAC_SHA256_V1 signing (3DES key derivation + HMAC-SHA256); notification endpoint at /api/redsys/notificacion handles server-to-server confirmation
- db.ts: questions table with createPendingQuestion / markQuestionPaid / getNextPendingQuestion / markQuestionUsed
- .env.sample: added AUTOPAUSE_DELAY_MS, PUBLIC_URL, REDSYS_* vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:03:30 +01:00
4b0b9f8f50 chore: remove all Twitch/streaming references
- Remove Twitch link from standings sidebar
- Delete scripts/stream-browser.ts (Twitch streaming script)
- Remove start:stream and start:stream:dryrun npm scripts
- Fix quipslop-export filename fallback in admin.tsx
- Fix hardcoded quipslop.sqlite in check-db.ts
- Rewrite README.md in Spanish, no Twitch mentions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:30:58 +01:00
2abea42c18 feat: convert to argument.es — Spanish, vote buttons, Docker
- Translate all ~430 prompts to Spanish with cultural adaptations
- Translate all UI strings (frontend, admin, history, broadcast)
- Translate AI system prompts; models now respond in Spanish
- Replace Twitch/Fossabot viewer voting with in-site vote buttons
- Add POST /api/vote endpoint (IP-based, supports vote switching)
- Vote buttons appear during voting phase with active state highlight
- Rename project to argument.es throughout (package.json, cookie, DB)
- Add docker-compose.yml with SQLite volume mount
- Add .env.sample documenting all required and optional vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:09:00 +01:00
Theo Browne
ccaa86b4a6 send on transitions 2026-02-22 21:50:01 -08:00
Theo Browne
79f9dab7fb new layout for model rankings 2026-02-22 18:44:49 -08:00
Theo Browne
f33277a095 update way faster 2026-02-22 18:32:51 -08:00
Theo Browne
af2f055939 dedupe 2026-02-22 18:28:36 -08:00
Theo Browne
41deee807a fossabot setup 2026-02-22 18:25:15 -08:00
Theo Browne
8f52bee72b Merge pull request #34 from matteomekhail/anti-spoof-thing
Anti spoof thing
2026-02-22 18:16:40 -08:00
Matteo Mekhail
8489927b67 normalize socket IP format 2026-02-23 13:12:42 +11:00
Matteo Mekhail
0295041cda add support for railway internal proxy 2026-02-23 13:10:25 +11:00
Theo Browne
eda80110c6 Merge pull request #31 from matteomekhail/anti-spoof-thing
This should fix the vuln from that script
2026-02-22 18:05:44 -08:00
Matteo Mekhail
0dcb6f71ab Suggestion from coderabbit and macros, both valid 2026-02-23 13:04:06 +11:00
Matteo Mekhail
ba543c1f25 This should fix the vuln from that script 2026-02-23 12:57:13 +11:00
24 changed files with 3947 additions and 1564 deletions

46
.env.sample Normal file
View File

@@ -0,0 +1,46 @@
# ── Required ──────────────────────────────────────────────────────────────────
# OpenRouter API key — get one at https://openrouter.ai/keys
OPENROUTER_API_KEY=sk-or-v1-...
# Admin panel password (choose something strong)
ADMIN_SECRET=change-me-please
# ── Optional ──────────────────────────────────────────────────────────────────
# Server port (default: 5109)
PORT=5109
# Path to the SQLite database file
# When using Docker, this is handled via the volume mount (/data/argumentes.sqlite)
DATABASE_PATH=argumentes.sqlite
# Rate limits (requests per minute)
HISTORY_LIMIT_PER_MIN=120
ADMIN_LIMIT_PER_MIN=10
# WebSocket limits
MAX_WS_GLOBAL=100000
MAX_WS_PER_IP=8
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

View File

@@ -1,3 +1,74 @@
# Quipslop
# argument.es
Streamed live on [twitch.tv/quipslop](https://twitch.tv/quipslop).
Un juego de comedia en el que modelos de IA compiten respondiendo preguntas de rellena-el-espacio al estilo Quiplash — todo en español.
Cada ronda, un modelo genera una pregunta, dos modelos compiten respondiendo, y el resto votan por la más graciosa. El público también puede votar directamente desde la web.
## Modelos participantes
- Gemini 3.1 Pro
- Kimi K2
- DeepSeek 3.2
- GPT-5.2
- Claude Opus 4.6
- Claude Sonnet 4.6
- Grok 4.1
## Inicio rápido
```bash
cp .env.sample .env
# Edita .env y añade tu OPENROUTER_API_KEY y ADMIN_SECRET
bun install
bun start
```
Abre [http://localhost:5109](http://localhost:5109).
## Docker
```bash
cp .env.sample .env
# Edita .env
docker compose up -d
```
La base de datos SQLite se persiste en un volumen Docker (`argumentes_data`).
## Variables de entorno
| Variable | Obligatoria | Por defecto | Descripción |
|---|---|---|---|
| `OPENROUTER_API_KEY` | ✅ | — | Clave de API de OpenRouter |
| `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.
## Panel de administración
Disponible en `/admin`. Funcionalidades:
- **Pausar / Reanudar** el bucle de juego
- **Exportar** todas las rondas como JSON
- **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.

View File

@@ -1,9 +1,9 @@
<!doctype html>
<html lang="en">
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>quipslop Admin</title>
<title>argument.es — Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link

View File

@@ -133,7 +133,7 @@ function App() {
const blob = await response.blob();
const disposition = response.headers.get("content-disposition") ?? "";
const fileNameMatch = disposition.match(/filename="([^"]+)"/i);
const fileName = fileNameMatch?.[1] ?? `quipslop-export-${Date.now()}.json`;
const fileName = fileNameMatch?.[1] ?? `argumentes-export-${Date.now()}.json`;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
@@ -192,7 +192,7 @@ function App() {
if (mode === "checking") {
return (
<div className="admin admin--centered">
<div className="loading">Checking admin session...</div>
<div className="loading">Comprobando sesión de administrador...</div>
</div>
);
}
@@ -202,12 +202,12 @@ function App() {
<div className="admin admin--centered">
<main className="panel panel--login">
<a href="/" className="logo-link">
<img src="/assets/logo.svg" alt="quipslop" />
<img src="/assets/logo.svg" alt="argument.es" />
</a>
<h1>Admin Access</h1>
<h1>Acceso de administrador</h1>
<p className="muted">
Enter your passcode once. A secure cookie will keep this browser
logged in.
Introduce tu contraseña una vez. Una cookie segura mantendrá
esta sesión activa en el navegador.
</p>
<form
@@ -218,7 +218,7 @@ function App() {
data-lpignore="true"
>
<label htmlFor="passcode" className="field-label">
Passcode
Contraseña
</label>
<input
id="passcode"
@@ -239,15 +239,15 @@ function App() {
data-1p-ignore
data-lpignore="true"
>
{pending === "login" ? "Checking..." : "Unlock Admin"}
{pending === "login" ? "Comprobando..." : "Desbloquear Admin"}
</button>
</form>
{error && <div className="error-banner">{error}</div>}
<div className="quick-links">
<a href="/">Live Game</a>
<a href="/history">History</a>
<a href="/">Juego en vivo</a>
<a href="/history">Historial</a>
</div>
</main>
</div>
@@ -258,23 +258,23 @@ function App() {
<div className="admin">
<header className="admin-header">
<a href="/" className="logo-link">
quipslop
argument.es
</a>
<nav className="quick-links">
<a href="/">Live Game</a>
<a href="/history">History</a>
<a href="/">Juego en vivo</a>
<a href="/history">Historial</a>
<button className="link-button" onClick={onLogout} disabled={busy}>
Logout
Cerrar sesión
</button>
</nav>
</header>
<main className="panel panel--main">
<div className="panel-head">
<h1>Admin Console</h1>
<h1>Consola de administrador</h1>
<p>
Pause/resume the game loop, export all data as JSON, or wipe all
stored data.
Pausa/reanuda el bucle del juego, exporta todos los datos en JSON
o borra todos los datos almacenados.
</p>
</div>
@@ -282,18 +282,18 @@ function App() {
<section className="status-grid" aria-live="polite">
<StatusCard
label="Engine"
value={snapshot?.isPaused ? "Paused" : "Running"}
label="Motor"
value={snapshot?.isPaused ? "En pausa" : "Ejecutándose"}
/>
<StatusCard
label="Active Round"
value={snapshot?.isRunningRound ? "In Progress" : "Idle"}
label="Ronda activa"
value={snapshot?.isRunningRound ? "En curso" : "Inactivo"}
/>
<StatusCard
label="Persisted Rounds"
label="Rondas guardadas"
value={String(snapshot?.persistedRounds ?? 0)}
/>
<StatusCard label="Viewers" value={String(snapshot?.viewerCount ?? 0)} />
<StatusCard label="Espectadores" value={String(snapshot?.viewerCount ?? 0)} />
</section>
<section className="actions" aria-label="Admin actions">
@@ -303,7 +303,7 @@ function App() {
disabled={busy || Boolean(snapshot?.isPaused)}
onClick={() => runControl("/api/admin/pause", "pause")}
>
{pending === "pause" ? "Pausing..." : "Pause"}
{pending === "pause" ? "Pausando..." : "Pausar"}
</button>
<button
type="button"
@@ -311,10 +311,10 @@ function App() {
disabled={busy || !snapshot?.isPaused}
onClick={() => runControl("/api/admin/resume", "resume")}
>
{pending === "resume" ? "Resuming..." : "Resume"}
{pending === "resume" ? "Reanudando..." : "Reanudar"}
</button>
<button type="button" className="btn" disabled={busy} onClick={onExport}>
{pending === "export" ? "Exporting..." : "Export JSON"}
{pending === "export" ? "Exportando..." : "Exportar JSON"}
</button>
<button
type="button"
@@ -322,7 +322,7 @@ function App() {
disabled={busy}
onClick={() => setIsResetOpen(true)}
>
Reset Data
Borrar datos
</button>
</section>
</main>
@@ -330,13 +330,13 @@ function App() {
{isResetOpen && (
<div className="modal-backdrop" role="dialog" aria-modal="true">
<div className="modal">
<h2>Reset all data?</h2>
<h2>¿Borrar todos los datos?</h2>
<p>
This permanently deletes every saved round and resets scores.
Current game flow is also paused.
Esto elimina permanentemente todas las rondas guardadas y
reinicia las puntuaciones. El juego también se pausará.
</p>
<p>
Type <code>{RESET_TOKEN}</code> to continue.
Escribe <code>{RESET_TOKEN}</code> para continuar.
</p>
<input
type="text"
@@ -356,7 +356,7 @@ function App() {
}}
disabled={busy}
>
Cancel
Cancelar
</button>
<button
type="button"
@@ -364,7 +364,7 @@ function App() {
onClick={onReset}
disabled={busy || resetText !== RESET_TOKEN}
>
{pending === "reset" ? "Resetting..." : "Confirm Reset"}
{pending === "reset" ? "Borrando..." : "Confirmar borrado"}
</button>
</div>
</div>

View File

@@ -1,9 +1,9 @@
<!doctype html>
<html lang="en">
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>quipslop Broadcast</title>
<title>argument.es — Transmisión</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link

View File

@@ -32,8 +32,10 @@ type GameState = {
lastCompleted: RoundState | null;
active: RoundState | null;
scores: Record<string, number>;
viewerScores: Record<string, number>;
done: boolean;
isPaused: boolean;
autoPaused?: boolean;
generation: number;
};
type StateMessage = {
@@ -290,12 +292,64 @@ function drawHeader() {
ctx.font = '700 40px "Inter", sans-serif';
ctx.fillStyle = "#ededed";
ctx.fillText("quipslop", 48, 76);
ctx.fillText("argument.es", 48, 76);
}
function drawScoreboard(scores: Record<string, number>) {
const entries = Object.entries(scores).sort((a, b) => b[1] - a[1]);
function drawScoreboardSection(
entries: [string, number][],
label: string,
startY: number,
entryHeight: number,
) {
const maxScore = entries[0]?.[1] || 1;
// Section label
ctx.font = '700 13px "JetBrains Mono", monospace';
ctx.fillStyle = "#555";
ctx.fillText(label, WIDTH - 348, startY);
// Divider line under label
ctx.fillStyle = "#1c1c1c";
ctx.fillRect(WIDTH - 348, startY + 8, 296, 1);
entries.forEach(([name, score], index) => {
const y = startY + 20 + index * entryHeight;
const color = getColor(name);
const pct = maxScore > 0 ? score / maxScore : 0;
ctx.font = '600 16px "JetBrains Mono", monospace';
ctx.fillStyle = "#555";
const rank = index === 0 && score > 0 ? "👑" : String(index + 1);
ctx.fillText(rank, WIDTH - 348, y + 18);
ctx.font = '600 16px "Inter", sans-serif';
ctx.fillStyle = color;
const nameText = name.length > 18 ? `${name.slice(0, 18)}...` : name;
const drewLogo = drawModelLogo(name, WIDTH - 310, y + 4, 20);
if (drewLogo) {
ctx.fillText(nameText, WIDTH - 310 + 26, y + 18);
} else {
ctx.fillText(nameText, WIDTH - 310, y + 18);
}
roundRect(WIDTH - 310, y + 30, 216, 3, 2, "#1c1c1c");
if (pct > 0) {
roundRect(WIDTH - 310, y + 30, Math.max(6, 216 * pct), 3, 2, color);
}
ctx.font = '700 16px "JetBrains Mono", monospace';
ctx.fillStyle = "#666";
const scoreText = String(score);
const scoreWidth = ctx.measureText(scoreText).width;
ctx.fillText(scoreText, WIDTH - 48 - scoreWidth, y + 18);
});
}
function drawScoreboard(scores: Record<string, number>, viewerScores: Record<string, number>) {
const modelEntries = Object.entries(scores).sort((a, b) => b[1] - a[1]) as [string, number][];
const viewerEntries = Object.entries(viewerScores).sort((a, b) => b[1] - a[1]) as [string, number][];
roundRect(WIDTH - 380, 0, 380, HEIGHT, 0, "#111");
ctx.fillStyle = "#1c1c1c";
@@ -305,40 +359,11 @@ function drawScoreboard(scores: Record<string, number>) {
ctx.fillStyle = "#888";
ctx.fillText("STANDINGS", WIDTH - 348, 76);
const maxScore = entries[0]?.[1] || 1;
const entryHeight = 52;
drawScoreboardSection(modelEntries, "AI JUDGES", 110, entryHeight);
entries.slice(0, 10).forEach(([name, score], index) => {
const y = 140 + index * 68;
const color = getColor(name);
const pct = maxScore > 0 ? (score / maxScore) : 0;
ctx.font = '600 20px "JetBrains Mono", monospace';
ctx.fillStyle = "#888";
const rank = index === 0 && score > 0 ? "👑" : String(index + 1);
ctx.fillText(rank, WIDTH - 348, y + 24);
ctx.font = '600 20px "Inter", sans-serif';
ctx.fillStyle = color;
const nameText = name.length > 18 ? `${name.slice(0, 18)}...` : name;
const drewLogo = drawModelLogo(name, WIDTH - 304, y + 6, 24);
if (drewLogo) {
ctx.fillText(nameText, WIDTH - 304 + 32, y + 24);
} else {
ctx.fillText(nameText, WIDTH - 304, y + 24);
}
roundRect(WIDTH - 304, y + 42, 208, 4, 2, "#1c1c1c");
if (pct > 0) {
roundRect(WIDTH - 304, y + 42, Math.max(8, 208 * pct), 4, 2, color);
}
ctx.font = '700 20px "JetBrains Mono", monospace';
ctx.fillStyle = "#888";
const scoreText = String(score);
const scoreWidth = ctx.measureText(scoreText).width;
ctx.fillText(scoreText, WIDTH - 48 - scoreWidth, y + 24);
});
const viewerStartY = 110 + 28 + modelEntries.length * entryHeight + 16;
drawScoreboardSection(viewerEntries, "VIEWERS", viewerStartY, entryHeight);
}
function drawRound(round: RoundState) {
@@ -608,7 +633,7 @@ function draw() {
return;
}
drawScoreboard(state.scores);
drawScoreboard(state.scores, state.viewerScores ?? {});
const isNextPrompting = state.active?.phase === "prompting" && !state.active.prompt;
const displayRound = isNextPrompting && state.lastCompleted ? state.lastCompleted : (state.active ?? state.lastCompleted ?? null);

View File

@@ -1,4 +1,4 @@
import { Database } from "bun:sqlite";
const db = new Database("quipslop.sqlite");
const db = new Database("argumentes.sqlite");
const rows = db.query("SELECT id, num FROM rounds ORDER BY id DESC LIMIT 20").all();
console.log(rows);

210
db.ts
View File

@@ -1,7 +1,7 @@
import { Database } from "bun:sqlite";
import type { RoundState } from "./game.ts";
const dbPath = process.env.DATABASE_PATH ?? "quipslop.sqlite";
const dbPath = process.env.DATABASE_PATH ?? "argumentes.sqlite";
export const db = new Database(dbPath, { create: true });
db.exec(`
@@ -41,3 +41,211 @@ export function clearAllRounds() {
db.exec("DELETE FROM rounds;");
db.exec("DELETE FROM sqlite_sequence WHERE name = 'rounds';");
}
// ── Questions (user-submitted) ───────────────────────────────────────────────
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',
username TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
// Migration: add username column to pre-existing questions tables
try {
db.exec("ALTER TABLE questions ADD COLUMN username TEXT NOT NULL DEFAULT ''");
} catch {
// Column already exists — no-op
}
export function createPendingQuestion(text: string, orderId: string, username = ""): number {
const stmt = db.prepare(
"INSERT INTO questions (text, order_id, username) VALUES ($text, $orderId, $username)"
);
const result = stmt.run({ $text: text, $orderId: orderId, $username: username });
return result.lastInsertRowid as number;
}
/** Creates a question that is immediately ready (used for credit-based submissions). */
export function createPaidQuestion(text: string, username: string): void {
const orderId = crypto.randomUUID();
db.prepare(
"INSERT INTO questions (text, order_id, username, status) VALUES ($text, $orderId, $username, 'paid')"
).run({ $text: text, $orderId: orderId, $username: username });
}
export function markQuestionPaid(orderId: string): boolean {
const result = db
.prepare("UPDATE questions SET status = 'paid' WHERE order_id = $orderId AND status = 'pending'")
.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 });
}
/** Top 7 players by total votes received on their answers, excluding anonymous. */
export function getPlayerScores(): Record<string, number> {
const rows = db
.query(
"SELECT username, SUM(votes) as score FROM user_answers WHERE username != '' GROUP BY username ORDER BY score DESC LIMIT 7"
)
.all() as { username: string; score: number }[];
return Object.fromEntries(rows.map(r => [r.username, r.score]));
}
/** Persist accumulated vote counts for user answers in a given round. */
export function persistUserAnswerVotes(roundNum: number, votes: Record<string, number>): void {
const stmt = db.prepare(
"UPDATE user_answers SET votes = $votes WHERE round_num = $roundNum AND username = $username"
);
db.transaction(() => {
for (const [username, voteCount] of Object.entries(votes)) {
stmt.run({ $votes: voteCount, $roundNum: roundNum, $username: username });
}
})();
}
// ── User answers (submitted during live rounds) ──────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS user_answers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
round_num INTEGER NOT NULL,
text TEXT NOT NULL,
username TEXT NOT NULL,
token TEXT NOT NULL,
votes INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
// Migration: add votes column to pre-existing user_answers tables
try {
db.exec("ALTER TABLE user_answers ADD COLUMN votes INTEGER NOT NULL DEFAULT 0");
} catch {
// Column already exists — no-op
}
// ── Credits (answer-count-based access) ──────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS credits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
tier TEXT NOT NULL,
order_id TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending',
expires_at INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
// Migrations for question-tracking columns
try {
db.exec("ALTER TABLE credits ADD COLUMN max_questions INTEGER");
} catch {
// Column already exists — no-op
}
try {
db.exec("ALTER TABLE credits ADD COLUMN questions_used INTEGER NOT NULL DEFAULT 0");
} catch {
// Column already exists — no-op
}
export function createPendingCredit(username: string, orderId: string, tier: string, maxQuestions: number | null): string {
const token = crypto.randomUUID();
db.prepare(
"INSERT INTO credits (username, token, tier, order_id, max_questions) VALUES ($username, $token, $tier, $orderId, $maxQuestions)"
).run({ $username: username, $token: token, $tier: tier, $orderId: orderId, $maxQuestions: maxQuestions });
return token;
}
export function activateCredit(
orderId: string,
expiresAt: number,
): { token: string; username: string } | null {
db.prepare(
"UPDATE credits SET status = 'active', expires_at = $expiresAt WHERE order_id = $orderId AND status = 'pending'"
).run({ $expiresAt: expiresAt, $orderId: orderId });
return db
.query("SELECT token, username FROM credits WHERE order_id = $orderId AND status = 'active'")
.get({ $orderId: orderId }) as { token: string; username: string } | null;
}
export function getCreditByOrder(orderId: string): {
status: string;
token: string;
username: string;
tier: string;
expiresAt: number | null;
maxQuestions: number | null;
questionsUsed: number;
} | null {
return db
.query(
"SELECT status, token, username, tier, expires_at as expiresAt, max_questions as maxQuestions, questions_used as questionsUsed FROM credits WHERE order_id = $orderId"
)
.get({ $orderId: orderId }) as {
status: string;
token: string;
username: string;
tier: string;
expiresAt: number | null;
maxQuestions: number | null;
questionsUsed: number;
} | null;
}
/** Insert a user answer directly, bypassing credit checks (admin use). */
export function insertAdminAnswer(roundNum: number, text: string, username: string): void {
db.prepare(
"INSERT INTO user_answers (round_num, text, username, token) VALUES ($roundNum, $text, $username, 'admin')"
).run({ $roundNum: roundNum, $text: text, $username: username });
}
/**
* Atomically validates a credit token, records a user answer for the given
* round, and decrements the answer budget. Returns null if the token is
* invalid or exhausted.
*/
export function submitUserAnswer(
token: string,
roundNum: number,
text: string,
): { username: string; answersLeft: number } | null {
const row = db
.query(
"SELECT username, max_questions, questions_used FROM credits WHERE token = $token AND status = 'active'"
)
.get({ $token: token }) as {
username: string;
max_questions: number;
questions_used: number;
} | null;
if (!row) return null;
if (row.questions_used >= row.max_questions) return null;
db.transaction(() => {
db.prepare(
"INSERT INTO user_answers (round_num, text, username, token) VALUES ($roundNum, $text, $username, $token)"
).run({ $roundNum: roundNum, $text: text, $username: row.username, $token: token });
db.prepare(
"UPDATE credits SET questions_used = questions_used + 1 WHERE token = $token"
).run({ $token: token });
})();
return { username: row.username, answersLeft: row.max_questions - row.questions_used - 1 };
}

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
services:
argumentes:
build: .
container_name: argument.es
restart: unless-stopped
ports:
- "${PORT:-5109}:${PORT:-5109}"
environment:
- NODE_ENV=production
- PORT=${PORT:-5109}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
- ADMIN_SECRET=${ADMIN_SECRET}
- DATABASE_PATH=/data/argumentes.sqlite
- VIEWER_VOTE_BROADCAST_DEBOUNCE_MS=${VIEWER_VOTE_BROADCAST_DEBOUNCE_MS:-250}
- HISTORY_LIMIT_PER_MIN=${HISTORY_LIMIT_PER_MIN:-120}
- ADMIN_LIMIT_PER_MIN=${ADMIN_LIMIT_PER_MIN:-10}
- MAX_WS_GLOBAL=${MAX_WS_GLOBAL:-100000}
- MAX_WS_PER_IP=${MAX_WS_PER_IP:-8}
- MAX_WS_NEW_PER_SEC=${MAX_WS_NEW_PER_SEC:-50}
volumes:
- argumentes_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${PORT:-5109}/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
argumentes_data:

View File

@@ -138,6 +138,57 @@ body {
color: var(--text-dim);
}
.vote-panel {
margin: -10px 0 22px;
display: flex;
flex-direction: column;
gap: 10px;
}
.vote-panel__label {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--text-muted);
}
.vote-panel__buttons {
display: flex;
gap: 10px;
}
.vote-btn {
flex: 1;
padding: 10px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
cursor: pointer;
font-family: var(--sans);
font-size: 13px;
transition: border-color 0.15s, background 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.vote-btn:hover {
border-color: #444;
background: #1a1a1a;
}
.vote-btn--active {
border-color: var(--accent);
background: rgba(217, 119, 87, 0.12);
}
.vote-btn--active:hover {
background: rgba(217, 119, 87, 0.18);
}
/* ── Prompt ───────────────────────────────────────────────────── */
.prompt {
@@ -404,8 +455,8 @@ body {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
max-height: 220px;
gap: 20px;
max-height: 50vh;
overflow-y: auto;
flex-shrink: 0;
}
@@ -442,56 +493,87 @@ body {
color: var(--text);
}
.standings__list {
/* ── Leaderboard Section ─────────────────────────────────────── */
.lb-section {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
}
.standing {
.lb-section__head {
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
margin-bottom: 2px;
}
.lb-section__label {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-muted);
}
.lb-section__list {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 0;
flex-direction: column;
}
.standing--active {
/* ── Leaderboard Entry ───────────────────────────────────────── */
.lb-entry {
padding: 6px 0 4px;
opacity: 0.55;
transition: opacity 0.3s;
}
.lb-entry--active,
.lb-entry:hover {
opacity: 1;
}
.standing__rank {
width: 22px;
.lb-entry__top {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 5px;
}
.lb-entry__rank {
width: 20px;
flex-shrink: 0;
text-align: center;
font-family: var(--mono);
font-size: 12px;
font-size: 11px;
color: var(--text-muted);
flex-shrink: 0;
}
.standing__bar {
flex: 1;
height: 3px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
min-width: 40px;
}
.standing__fill {
height: 100%;
border-radius: 2px;
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.standing__score {
.lb-entry__score {
margin-left: auto;
font-family: var(--mono);
font-size: 12px;
font-weight: 700;
color: var(--text-dim);
min-width: 16px;
min-width: 14px;
text-align: right;
}
.lb-entry__bar {
margin-left: 26px;
height: 3px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.lb-entry__fill {
height: 100%;
border-radius: 2px;
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
/* ── Connecting ───────────────────────────────────────────────── */
.connecting {
@@ -654,6 +736,312 @@ body {
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* ── Site footer (inside main) ───────────────────────────────── */
.site-footer {
flex-shrink: 0;
margin-top: 32px;
padding: 16px 0 4px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
line-height: 1.9;
}
.site-footer a {
color: var(--text-dim);
text-decoration: none;
}
.site-footer a:hover {
color: var(--text);
text-decoration: underline;
}
/* ── Player leaderboard name ─────────────────────────────────── */
.lb-entry__name {
font-size: 12px;
font-weight: 500;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── User answers (audience) ─────────────────────────────────── */
.user-answers {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.user-answers__label {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 8px;
}
.user-answers__list {
display: flex;
flex-direction: column;
gap: 6px;
}
.user-answer {
font-size: 13px;
line-height: 1.4;
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
}
.user-answer__main {
flex: 1;
min-width: 0;
}
.user-answer__vote {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
.user-vote-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
font-size: 10px;
padding: 2px 7px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
line-height: 1.4;
}
.user-vote-btn:hover {
border-color: #444;
color: var(--text-dim);
}
.user-vote-btn--active {
border-color: var(--accent);
color: var(--accent);
}
.user-vote-count {
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
min-width: 14px;
text-align: right;
}
.user-answer__name {
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
color: var(--accent);
}
.user-answer__sep {
color: var(--text-muted);
}
.user-answer__text {
color: var(--text-dim);
font-family: var(--serif);
font-size: 15px;
}
/* ── Propose Answer widget ───────────────────────────────────── */
.propose {
flex-shrink: 0;
border-top: 1px solid var(--border);
padding: 14px 0 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.propose__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.propose__title {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--text-muted);
}
.propose__badge {
font-size: 11px;
font-weight: 600;
color: #4caf7d;
background: rgba(76, 175, 125, 0.12);
border: 1px solid rgba(76, 175, 125, 0.25);
border-radius: 20px;
padding: 2px 10px;
white-space: nowrap;
}
.propose__badge--empty {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.03);
border-color: var(--border);
}
.propose__tiers {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.propose__tier {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 6px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 7px;
cursor: pointer;
font-family: var(--sans);
color: var(--text);
transition: border-color 0.15s, background 0.15s;
}
.propose__tier:hover { border-color: #444; }
.propose__tier--selected {
border-color: var(--accent);
background: rgba(217, 119, 87, 0.08);
}
.propose__tier__price {
font-size: 14px;
font-weight: 700;
color: var(--accent);
}
.propose__tier__label {
font-size: 10px;
color: var(--text-dim);
}
.propose__row {
display: flex;
gap: 6px;
align-items: flex-start;
}
.propose__row--mt { margin-top: 2px; }
.propose__input {
flex: 1;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 10px;
color: var(--text);
font-family: var(--sans);
font-size: 13px;
min-width: 0;
}
.propose__input:focus { outline: none; border-color: #444; }
.propose__input::placeholder { color: var(--text-muted); }
.propose__textarea {
flex: 1;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 10px;
color: var(--text);
font-family: var(--sans);
font-size: 13px;
line-height: 1.4;
resize: none;
min-width: 0;
}
.propose__textarea:focus { outline: none; border-color: #444; }
.propose__textarea::placeholder { color: var(--text-muted); }
.propose__btn {
padding: 7px 14px;
background: var(--accent);
border: none;
border-radius: 6px;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
font-family: var(--sans);
white-space: nowrap;
flex-shrink: 0;
transition: opacity 0.15s;
}
.propose__btn:hover:not(:disabled) { opacity: 0.85; }
.propose__btn:disabled { opacity: 0.35; cursor: not-allowed; }
.propose__hint {
font-size: 11px;
color: var(--text-muted);
font-family: var(--mono);
}
.propose__msg {
font-size: 12px;
margin: 0;
}
.propose__msg--ok { color: #4caf7d; }
.propose__msg--error { color: #ff6b6b; }
.propose__spinner {
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: propose-spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes propose-spin { to { transform: rotate(360deg); } }
.propose__link-btn {
background: none;
border: none;
padding: 0;
color: var(--text-muted);
font-size: 11px;
font-family: var(--mono);
cursor: pointer;
text-decoration: underline;
}
.propose__link-btn:hover { color: var(--text-dim); }
/* ── Desktop (1024px+) ───────────────────────────────────────── */
@media (min-width: 1024px) {
@@ -707,5 +1095,7 @@ body {
max-height: none;
overflow-y: auto;
padding: 24px;
gap: 24px;
}
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import "./frontend.css";
@@ -33,13 +33,17 @@ type RoundState = {
viewerVotesA?: number;
viewerVotesB?: number;
viewerVotingEndsAt?: number;
userAnswers?: { username: string; text: string }[];
userAnswerVotes?: Record<string, number>;
};
type GameState = {
lastCompleted: RoundState | null;
active: RoundState | null;
scores: Record<string, number>;
viewerScores: Record<string, number>;
done: boolean;
isPaused: boolean;
autoPaused?: boolean;
generation: number;
};
type StateMessage = {
@@ -53,8 +57,70 @@ type ViewerCountMessage = {
type: "viewerCount";
viewerCount: number;
};
type VotedAckMessage = { type: "votedAck"; votedFor: "A" | "B" };
type ServerMessage = StateMessage | ViewerCountMessage | VotedAckMessage;
type ServerMessage = StateMessage | ViewerCountMessage;
// ── Credit / Propose ─────────────────────────────────────────────────────────
type CreditInfo = {
token: string;
username: string;
expiresAt: number;
tier: string;
answersLeft: number;
};
const CREDIT_STORAGE_KEY = "argumentes_credito";
const PROPOSE_TIERS = [
{ id: "basico", label: "10 respuestas", price: "0,99€" },
{ id: "pro", label: "300 respuestas", price: "9,99€" },
{ id: "full", label: "1000 respuestas", price: "19,99€" },
] as const;
type ProposeTierId = (typeof PROPOSE_TIERS)[number]["id"];
function loadCredit(): CreditInfo | null {
try {
const raw = localStorage.getItem(CREDIT_STORAGE_KEY);
if (!raw) return null;
const c = JSON.parse(raw) as CreditInfo & { questionsLeft?: number };
if (!c.token || !c.expiresAt || c.expiresAt < Date.now()) {
localStorage.removeItem(CREDIT_STORAGE_KEY);
return null;
}
// Migrate old field name
if (c.answersLeft === undefined && c.questionsLeft !== undefined) {
c.answersLeft = c.questionsLeft;
}
return c as CreditInfo;
} catch {
return null;
}
}
async function submitRedsysForm(data: {
tpvUrl: string;
merchantParams: string;
signature: string;
signatureVersion: string;
}) {
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();
}
// ── Model colors & logos ─────────────────────────────────────────────────────
@@ -120,7 +186,7 @@ function PromptCard({ round }: { round: RoundState }) {
return (
<div className="prompt">
<div className="prompt__by">
<ModelTag model={round.prompter} small /> is writing a prompt
<ModelTag model={round.prompter} small /> está escribiendo una pregunta
<Dots />
</div>
<div className="prompt__text prompt__text--loading">
@@ -134,7 +200,7 @@ function PromptCard({ round }: { round: RoundState }) {
return (
<div className="prompt">
<div className="prompt__text prompt__text--error">
Prompt generation failed
Error al generar la pregunta
</div>
</div>
);
@@ -143,7 +209,7 @@ function PromptCard({ round }: { round: RoundState }) {
return (
<div className="prompt">
<div className="prompt__by">
Prompted by <ModelTag model={round.prompter} small />
Pregunta de <ModelTag model={round.prompter} small />
</div>
<div className="prompt__text">{round.prompt}</div>
</div>
@@ -161,9 +227,6 @@ function ContestantCard({
voters,
viewerVotes,
totalViewerVotes,
votable,
onVote,
isMyVote,
}: {
task: TaskInfo;
voteCount: number;
@@ -173,9 +236,6 @@ function ContestantCard({
voters: VoteInfo[];
viewerVotes?: number;
totalViewerVotes?: number;
votable?: boolean;
onVote?: () => void;
isMyVote?: boolean;
}) {
const color = getColor(task.model.name);
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
@@ -186,17 +246,12 @@ function ContestantCard({
return (
<div
className={`contestant ${isWinner ? "contestant--winner" : ""} ${votable ? "contestant--votable" : ""} ${isMyVote ? "contestant--my-vote" : ""}`}
className={`contestant ${isWinner ? "contestant--winner" : ""}`}
style={{ "--accent": color } as React.CSSProperties}
onClick={votable ? onVote : undefined}
role={votable ? "button" : undefined}
tabIndex={votable ? 0 : undefined}
onKeyDown={votable ? (e) => { if (e.key === "Enter" || e.key === " ") onVote?.(); } : undefined}
>
<div className="contestant__head">
<ModelTag model={task.model} />
{isMyVote && !isWinner && <span className="my-vote-tag">YOUR PICK</span>}
{isWinner && <span className="win-tag">WIN</span>}
{isWinner && <span className="win-tag">GANA</span>}
</div>
<div className="contestant__body">
@@ -224,7 +279,7 @@ function ContestantCard({
{voteCount}
</span>
<span className="vote-meta__label">
vote{voteCount !== 1 ? "s" : ""}
voto{voteCount !== 1 ? "s" : ""}
</span>
<span className="vote-meta__dots">
{voters.map((v, i) => {
@@ -263,7 +318,7 @@ function ContestantCard({
{viewerVotes ?? 0}
</span>
<span className="vote-meta__label">
viewer vote{(viewerVotes ?? 0) !== 1 ? "s" : ""}
voto{(viewerVotes ?? 0) !== 1 ? "s" : ""} del público
</span>
<span className="viewer-vote-meta__icon">👥</span>
</div>
@@ -280,15 +335,19 @@ function ContestantCard({
function Arena({
round,
total,
viewerVotingSecondsLeft,
myVote,
onVote,
viewerVotingSecondsLeft,
myUserAnswerVote,
onUserAnswerVote,
}: {
round: RoundState;
total: number | null;
viewerVotingSecondsLeft: number;
myVote: "A" | "B" | null;
onVote: (side: "A" | "B") => void;
viewerVotingSecondsLeft: number;
myUserAnswerVote: string | null;
onUserAnswerVote: (username: string) => void;
}) {
const [contA, contB] = round.contestants;
const showVotes = round.phase === "voting" || round.phase === "done";
@@ -305,22 +364,16 @@ function Arena({
const votersB = round.votes.filter((v) => v.votedFor?.name === contB.name);
const totalViewerVotes = (round.viewerVotesA ?? 0) + (round.viewerVotesB ?? 0);
const canVote =
round.phase === "voting" &&
viewerVotingSecondsLeft > 0 &&
round.answerTasks[0].finishedAt &&
round.answerTasks[1].finishedAt;
const showCountdown = round.phase === "voting" && viewerVotingSecondsLeft > 0;
const phaseText =
round.phase === "prompting"
? "Writing prompt"
? "Generando pregunta"
: round.phase === "answering"
? "Answering"
? "Respondiendo"
: round.phase === "voting"
? "Judges voting"
: "Complete";
? "Votando los jueces"
: "Completado";
return (
<div className="arena">
@@ -336,6 +389,25 @@ function Arena({
)}
</span>
</div>
{showCountdown && (
<div className="vote-panel">
<span className="vote-panel__label">¿Cuál es más gracioso?</span>
<div className="vote-panel__buttons">
<button
className={`vote-btn ${myVote === "A" ? "vote-btn--active" : ""}`}
onClick={() => onVote("A")}
>
<ModelTag model={contA} />
</button>
<button
className={`vote-btn ${myVote === "B" ? "vote-btn--active" : ""}`}
onClick={() => onVote("B")}
>
<ModelTag model={contB} />
</button>
</div>
</div>
)}
<PromptCard round={round} />
@@ -350,9 +422,6 @@ function Arena({
voters={votersA}
viewerVotes={round.viewerVotesA}
totalViewerVotes={totalViewerVotes}
votable={!!canVote}
onVote={() => onVote("A")}
isMyVote={myVote === "A"}
/>
<ContestantCard
task={round.answerTasks[1]}
@@ -363,15 +432,49 @@ function Arena({
voters={votersB}
viewerVotes={round.viewerVotesB}
totalViewerVotes={totalViewerVotes}
votable={!!canVote}
onVote={() => onVote("B")}
isMyVote={myVote === "B"}
/>
</div>
)}
{isDone && votesA === votesB && totalVotes > 0 && (
<div className="tie-label">Tie</div>
<div className="tie-label">Empate</div>
)}
{round.userAnswers && round.userAnswers.length > 0 && (
<div className="user-answers">
<div className="user-answers__label">Respuestas del público</div>
<div className="user-answers__list">
{round.userAnswers.map((a, i) => {
const voteCount = round.userAnswerVotes?.[a.username] ?? 0;
const isMyVote = myUserAnswerVote === a.username;
return (
<div key={i} className="user-answer">
<div className="user-answer__main">
<span className="user-answer__name">{a.username}</span>
<span className="user-answer__sep"> </span>
<span className="user-answer__text">&ldquo;{a.text}&rdquo;</span>
</div>
{(showCountdown || voteCount > 0) && (
<div className="user-answer__vote">
{showCountdown && (
<button
className={`user-vote-btn ${isMyVote ? "user-vote-btn--active" : ""}`}
onClick={() => onUserAnswerVote(a.username)}
title="Votar"
>
</button>
)}
{voteCount > 0 && (
<span className="user-vote-count">{voteCount}</span>
)}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
@@ -385,7 +488,7 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
return (
<div className="game-over">
<div className="game-over__label">Game Over</div>
<div className="game-over__label">Fin del Juego</div>
{champion && champion[1] > 0 && (
<div className="game-over__winner">
<span className="game-over__crown">👑</span>
@@ -396,7 +499,7 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
{getLogo(champion[0]) && <img src={getLogo(champion[0])!} alt="" />}
{champion[0]}
</span>
<span className="game-over__sub">is the funniest AI</span>
<span className="game-over__sub">es la IA más graciosa</span>
</div>
)}
</div>
@@ -405,16 +508,100 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
// ── Standings ────────────────────────────────────────────────────────────────
function Standings({
function LeaderboardSection({
label,
scores,
activeRound,
competing,
}: {
label: string;
scores: Record<string, number>;
activeRound: RoundState | null;
competing: Set<string>;
}) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const maxScore = sorted[0]?.[1] || 1;
return (
<div className="lb-section">
<div className="lb-section__head">
<span className="lb-section__label">{label}</span>
</div>
<div className="lb-section__list">
{sorted.map(([name, score], i) => {
const pct = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0;
const color = getColor(name);
const active = competing.has(name);
return (
<div
key={name}
className={`lb-entry ${active ? "lb-entry--active" : ""}`}
>
<div className="lb-entry__top">
<span className="lb-entry__rank">
{i === 0 && score > 0 ? "👑" : i + 1}
</span>
<ModelTag model={{ id: name, name }} small />
<span className="lb-entry__score">{score}</span>
</div>
<div className="lb-entry__bar">
<div
className="lb-entry__fill"
style={{ width: `${pct}%`, background: color }}
/>
</div>
</div>
);
})}
</div>
</div>
);
}
function PlayerLeaderboard({ scores }: { scores: Record<string, number> }) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]).slice(0, 7);
const maxScore = sorted[0]?.[1] || 1;
return (
<div className="lb-section">
<div className="lb-section__head">
<span className="lb-section__label">Jugadores</span>
<a href="/pregunta" className="standings__link">Jugar</a>
</div>
<div className="lb-section__list">
{sorted.map(([name, score], i) => {
const pct = Math.round((score / maxScore) * 100);
return (
<div key={name} className="lb-entry lb-entry--active">
<div className="lb-entry__top">
<span className="lb-entry__rank">
{i === 0 && score > 0 ? "🏆" : i + 1}
</span>
<span className="lb-entry__name">{name}</span>
<span className="lb-entry__score">{score}</span>
</div>
<div className="lb-entry__bar">
<div
className="lb-entry__fill"
style={{ width: `${pct}%`, background: "var(--accent)" }}
/>
</div>
</div>
);
})}
</div>
</div>
);
}
function Standings({
scores,
viewerScores,
playerScores,
activeRound,
}: {
scores: Record<string, number>;
viewerScores: Record<string, number>;
playerScores: Record<string, number>;
activeRound: RoundState | null;
}) {
const competing = activeRound
? new Set([
activeRound.contestants[0].name,
@@ -425,58 +612,356 @@ function Standings({
return (
<aside className="standings">
<div className="standings__head">
<span className="standings__title">Standings</span>
<span className="standings__title">Clasificación</span>
<div className="standings__links">
<a href="/history" className="standings__link">
History
Historial
</a>
<a href="https://twitch.tv/quipslop" target="_blank" rel="noopener noreferrer" className="standings__link">
Twitch
</a>
<a href="https://github.com/T3-Content/quipslop" target="_blank" rel="noopener noreferrer" className="standings__link">
GitHub
<a href="https://argument.es" target="_blank" rel="noopener noreferrer" className="standings__link">
Web
</a>
</div>
</div>
<div className="standings__list">
{sorted.map(([name, score], i) => {
const pct = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0;
const color = getColor(name);
const active = competing.has(name);
return (
<div
key={name}
className={`standing ${active ? "standing--active" : ""}`}
>
<span className="standing__rank">
{i === 0 && score > 0 ? "👑" : i + 1}
</span>
<ModelTag model={{ id: name, name }} small />
<div className="standing__bar">
<div
className="standing__fill"
style={{ width: `${pct}%`, background: color }}
/>
</div>
<span className="standing__score">{score}</span>
</div>
);
})}
</div>
<LeaderboardSection
label="Jueces IA"
scores={scores}
competing={competing}
/>
<LeaderboardSection
label="Público"
scores={viewerScores}
competing={competing}
/>
{Object.keys(playerScores).length > 0 && (
<PlayerLeaderboard scores={playerScores} />
)}
</aside>
);
}
// ── Propose Answer (inline widget) ───────────────────────────────────────────
function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
const params = new URLSearchParams(window.location.search);
const creditOkOrder = params.get("credito_ok");
const isKo = params.get("ko") === "1";
const [credit, setCredit] = useState<CreditInfo | null>(null);
const [loaded, setLoaded] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [verifying, setVerifying] = useState(false);
const [verifyError, setVerifyError] = useState(false);
const [selectedTier, setSelectedTier] = useState<ProposeTierId | null>(null);
const [username, setUsername] = useState("");
const [buying, setBuying] = useState(false);
const [buyError, setBuyError] = useState<string | null>(null);
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submittedFor, setSubmittedFor] = useState<number | null>(null);
const [submittedText, setSubmittedText] = useState<string | null>(null);
const [koDismissed, setKoDismissed] = useState(false);
useEffect(() => {
setCredit(loadCredit());
setLoaded(true);
// Check if admin is logged in (cookie-based, no token needed)
fetch("/api/admin/status")
.then(r => { if (r.ok) setIsAdmin(true); })
.catch(() => {});
}, []);
// Clear submission state when a new round starts
useEffect(() => {
if (activeRound?.num && submittedFor !== null && activeRound.num !== submittedFor) {
setSubmittedFor(null);
setSubmittedText(null);
setText("");
}
}, [activeRound?.num]);
useEffect(() => {
if (!creditOkOrder || !loaded || credit) return;
setVerifying(true);
let attempts = 0;
async function poll() {
if (attempts >= 15) { setVerifying(false); setVerifyError(true); return; }
attempts++;
try {
const res = await fetch(`/api/credito/estado?order=${encodeURIComponent(creditOkOrder!)}`);
if (res.ok) {
const data = await res.json() as {
found: boolean; status?: string; token?: string;
username?: string; expiresAt?: number; tier?: string;
answersLeft?: number;
};
if (data.found && data.status === "active" && data.token && data.expiresAt) {
const newCredit: CreditInfo = {
token: data.token, username: data.username ?? "",
expiresAt: data.expiresAt, tier: data.tier ?? "",
answersLeft: data.answersLeft ?? 0,
};
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(newCredit));
setCredit(newCredit);
setVerifying(false);
history.replaceState(null, "", "/");
return;
}
}
} catch { /* retry */ }
setTimeout(poll, 2000);
}
poll();
}, [creditOkOrder, loaded, credit]);
async function handleBuyCredit(e: React.FormEvent) {
e.preventDefault();
if (!selectedTier) return;
setBuyError(null);
setBuying(true);
try {
const res = await fetch("/api/credito/iniciar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tier: selectedTier, username: username.trim() }),
});
if (!res.ok) throw new Error(await res.text());
await submitRedsysForm(await res.json());
} catch (err) {
setBuyError(err instanceof Error ? err.message : "Error al procesar el pago");
setBuying(false);
}
}
async function handleSubmitAnswer(e: React.FormEvent) {
e.preventDefault();
if (!credit && !isAdmin) return;
if (!activeRound) return;
setSubmitError(null);
setSubmitting(true);
try {
const res = await fetch("/api/respuesta/enviar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: text.trim(), token: credit?.token ?? "" }),
});
if (!res.ok) {
if (res.status === 401) {
if (!isAdmin) {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
}
throw new Error("Crédito agotado o no válido.");
}
if (res.status === 409) throw new Error("La ronda aún no tiene pregunta activa.");
throw new Error(await res.text() || `Error ${res.status}`);
}
const data = await res.json() as { ok: boolean; answersLeft: number };
if (credit) {
const updated: CreditInfo = { ...credit, answersLeft: data.answersLeft };
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated));
setCredit(updated);
}
setSubmittedFor(activeRound.num);
setSubmittedText(text.trim());
setText("");
} catch (err) {
setSubmitError(err instanceof Error ? err.message : "Error al enviar");
} finally {
setSubmitting(false);
}
}
if (!loaded) return null;
const tierInfo = selectedTier ? PROPOSE_TIERS.find(t => t.id === selectedTier) : null;
const exhausted = credit !== null && credit.answersLeft <= 0;
const hasPrompt = !!(activeRound?.prompt);
const alreadySubmitted = submittedFor === activeRound?.num;
const phaseOk = activeRound?.phase === "answering" || activeRound?.phase === "prompting";
const canAnswer = (isAdmin || (credit && !exhausted)) && hasPrompt && !alreadySubmitted && phaseOk;
// Verifying payment
if (verifying || (creditOkOrder && !credit)) {
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Verificando pago</span>
{!verifyError && <div className="propose__spinner" />}
</div>
{verifyError && (
<p className="propose__msg propose__msg--error">
No se pudo confirmar. Recarga si el pago se completó.
</p>
)}
</div>
);
}
// Active credit
if (credit) {
const badge = `${credit.answersLeft} respuesta${credit.answersLeft !== 1 ? "s" : ""}`;
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Responde junto a las IAs · {credit.username}</span>
<span className={`propose__badge ${exhausted ? "propose__badge--empty" : ""}`}>{badge}</span>
</div>
{alreadySubmitted && submittedText && (
<p className="propose__msg propose__msg--ok">
Tu respuesta: &ldquo;{submittedText}&rdquo;
</p>
)}
{canAnswer && (
<form onSubmit={handleSubmitAnswer}>
<div className="propose__row">
<textarea
className="propose__textarea"
value={text}
onChange={e => setText(e.target.value)}
placeholder="Tu respuesta más graciosa…"
rows={2}
maxLength={150}
autoFocus
/>
<button className="propose__btn" type="submit" disabled={submitting || text.trim().length < 3}>
{submitting ? "…" : "Enviar"}
</button>
</div>
<div className="propose__hint">
{text.length}/150 ·{" "}
<button type="button" className="propose__link-btn" onClick={() => {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
}}>
cerrar sesión
</button>
</div>
{submitError && <p className="propose__msg propose__msg--error">{submitError}</p>}
</form>
)}
{!canAnswer && !alreadySubmitted && !exhausted && (
<p className="propose__msg" style={{ color: "var(--text-muted)" }}>
{!hasPrompt ? "Esperando la siguiente pregunta…" : "Ya no se aceptan respuestas para esta ronda."}
</p>
)}
{exhausted && (
<div className="propose__row">
<p className="propose__msg propose__msg--error" style={{ flex: 1 }}>
Sin respuestas. Recarga para seguir jugando.
</p>
<button className="propose__btn" onClick={() => {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
}}>Recargar</button>
</div>
)}
</div>
);
}
// Admin: show answer form without requiring credit
if (isAdmin) {
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Responde junto a las IAs</span>
<span className="propose__badge" style={{ color: "var(--accent)", borderColor: "var(--accent)", background: "rgba(217,119,87,0.1)" }}>Admin</span>
</div>
{alreadySubmitted && submittedText && (
<p className="propose__msg propose__msg--ok"> Tu respuesta: &ldquo;{submittedText}&rdquo;</p>
)}
{canAnswer && (
<form onSubmit={handleSubmitAnswer}>
<div className="propose__row">
<textarea
className="propose__textarea"
value={text}
onChange={e => setText(e.target.value)}
placeholder="Tu respuesta más graciosa…"
rows={2}
maxLength={150}
autoFocus
/>
<button className="propose__btn" type="submit" disabled={submitting || text.trim().length < 3}>
{submitting ? "…" : "Enviar"}
</button>
</div>
<div className="propose__hint">{text.length}/150</div>
{submitError && <p className="propose__msg propose__msg--error">{submitError}</p>}
</form>
)}
{!canAnswer && !alreadySubmitted && (
<p className="propose__msg" style={{ color: "var(--text-muted)" }}>
{!hasPrompt ? "Esperando la siguiente pregunta…" : "Ya no se aceptan respuestas para esta ronda."}
</p>
)}
</div>
);
}
// Tier selection (purchase)
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Responde junto a las IAs</span>
</div>
{isKo && !koDismissed && (
<p className="propose__msg propose__msg--error">
El pago no se completó.{" "}
<button type="button" className="propose__link-btn" onClick={() => setKoDismissed(true)}>×</button>
</p>
)}
<div className="propose__tiers">
{PROPOSE_TIERS.map(tier => (
<button
key={tier.id}
type="button"
className={`propose__tier ${selectedTier === tier.id ? "propose__tier--selected" : ""}`}
onClick={() => setSelectedTier(tier.id)}
>
<span className="propose__tier__price">{tier.price}</span>
<span className="propose__tier__label">{tier.label}</span>
</button>
))}
</div>
{selectedTier && (
<form onSubmit={handleBuyCredit} className="propose__row propose__row--mt">
<input
type="text"
className="propose__input"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="Tu nombre en el marcador"
maxLength={30}
required
autoFocus
/>
<button type="submit" className="propose__btn" disabled={buying || !username.trim()}>
{buying ? "…" : `Pagar ${tierInfo?.price}`}
</button>
</form>
)}
{buyError && <p className="propose__msg propose__msg--error">{buyError}</p>}
</div>
);
}
// ── Connecting ───────────────────────────────────────────────────────────────
function ConnectingScreen() {
return (
<div className="connecting">
<div className="connecting__logo">
<img src="/assets/logo.svg" alt="quipslop" />
<img src="/assets/logo.svg" alt="argument.es" />
</div>
<div className="connecting__sub">
Connecting
Conectando
<Dots />
</div>
</div>
@@ -490,19 +975,25 @@ function App() {
const [totalRounds, setTotalRounds] = useState<number | null>(null);
const [viewerCount, setViewerCount] = useState(0);
const [connected, setConnected] = useState(false);
const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
const [votedRound, setVotedRound] = useState<number | null>(null);
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
const wsRef = React.useRef<WebSocket | null>(null);
const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
const [myUserAnswerVote, setMyUserAnswerVote] = useState<string | null>(null);
const lastVotedRoundRef = useRef<number | null>(null);
const [playerScores, setPlayerScores] = useState<Record<string, number>>({});
// Reset vote when round changes
useEffect(() => {
const currentRound = state?.active?.num ?? null;
if (currentRound !== null && currentRound !== votedRound) {
setMyVote(null);
setVotedRound(null);
async function fetchPlayerScores() {
try {
const res = await fetch("/api/jugadores");
if (res.ok) setPlayerScores(await res.json());
} catch {
// ignore
}
}
}, [state?.active?.num, votedRound]);
fetchPlayerScores();
const interval = setInterval(fetchPlayerScores, 60_000);
return () => clearInterval(interval);
}, []);
// Countdown timer for viewer voting
useEffect(() => {
@@ -521,6 +1012,42 @@ function App() {
return () => clearInterval(interval);
}, [state?.active?.viewerVotingEndsAt, state?.active?.phase]);
// Reset my votes when a new round starts
useEffect(() => {
const roundNum = state?.active?.num ?? null;
if (roundNum !== null && roundNum !== lastVotedRoundRef.current) {
setMyVote(null);
setMyUserAnswerVote(null);
lastVotedRoundRef.current = roundNum;
}
}, [state?.active?.num]);
async function handleVote(side: "A" | "B") {
setMyVote(side);
try {
await fetch("/api/vote", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ side }),
});
} catch {
// ignore network errors
}
}
async function handleUserAnswerVote(username: string) {
setMyUserAnswerVote(username);
try {
await fetch("/api/vote/respuesta", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
} catch {
// ignore network errors
}
}
useEffect(() => {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
@@ -530,11 +1057,9 @@ function App() {
let knownVersion: string | null = null;
function connect() {
ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => setConnected(true);
ws.onclose = () => {
setConnected(false);
wsRef.current = null;
reconnectTimer = setTimeout(connect, 2000);
};
ws.onmessage = (e) => {
@@ -549,8 +1074,6 @@ function App() {
setViewerCount(msg.viewerCount);
} else if (msg.type === "viewerCount") {
setViewerCount(msg.viewerCount);
} else if (msg.type === "votedAck") {
setMyVote(msg.votedFor);
}
};
}
@@ -562,13 +1085,6 @@ function App() {
};
}, []);
const handleVote = (side: "A" | "B") => {
if (myVote === side || !wsRef.current) return;
wsRef.current.send(JSON.stringify({ type: "vote", votedFor: side }));
setMyVote(side);
setVotedRound(state?.active?.num ?? null);
};
if (!connected || !state) return <ConnectingScreen />;
const isNextPrompting =
@@ -582,7 +1098,7 @@ function App() {
<main className="main">
<header className="header">
<a href="/" className="logo">
<img src="/assets/logo.svg" alt="quipslop" />
<img src="/assets/logo.svg" alt="argument.es" />
</a>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
{state.isPaused && (
@@ -590,12 +1106,12 @@ function App() {
className="viewer-pill"
style={{ color: "var(--text-muted)", borderColor: "var(--border)" }}
>
Paused
{state.autoPaused ? "Esperando espectadores…" : "En pausa"}
</div>
)}
<div className="viewer-pill" aria-live="polite">
<span className="viewer-pill__dot" />
{viewerCount} viewer{viewerCount === 1 ? "" : "s"} watching
{viewerCount} espectador{viewerCount === 1 ? "" : "es"} conectado{viewerCount === 1 ? "" : "s"}
</div>
</div>
</header>
@@ -606,27 +1122,47 @@ function App() {
<Arena
round={displayRound}
total={totalRounds}
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
myVote={myVote}
onVote={handleVote}
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
myUserAnswerVote={myUserAnswerVote}
onUserAnswerVote={handleUserAnswerVote}
/>
) : (
<div className="waiting">
Starting
Iniciando
<Dots />
</div>
)}
{isNextPrompting && state.lastCompleted && (
<div className="next-toast">
<ModelTag model={state.active!.prompter} small /> is writing the
next prompt
<ModelTag model={state.active!.prompter} small /> está escribiendo
la siguiente pregunta
<Dots />
</div>
)}
<ProposeAnswer activeRound={state.active} />
<footer className="site-footer">
<p>IAs compiten respondiendo preguntas absurdas los jueces votan, también puedes.</p>
<p>
por{" "}
<a href="https://cloudhost.es" target="_blank" rel="noopener noreferrer">
Cloud Host
</a>
{" "} La web simplificada, la nube gestionada
</p>
</footer>
</main>
<Standings scores={state.scores} activeRound={state.active} />
<Standings
scores={state.scores}
viewerScores={state.viewerScores ?? {}}
playerScores={playerScores}
activeRound={state.active}
/>
</div>
</div>
);

60
game.ts
View File

@@ -67,14 +67,18 @@ export type RoundState = {
viewerVotesA?: number;
viewerVotesB?: number;
viewerVotingEndsAt?: number;
userAnswers?: { username: string; text: string }[];
userAnswerVotes?: Record<string, number>;
};
export type GameState = {
completed: RoundState[];
active: RoundState | null;
scores: Record<string, number>;
viewerScores: Record<string, number>;
done: boolean;
isPaused: boolean;
autoPaused: boolean;
generation: number;
};
@@ -190,13 +194,13 @@ import { ALL_PROMPTS } from "./prompts";
function buildPromptSystem(): string {
const examples = shuffle([...ALL_PROMPTS]).slice(0, 80);
return `You are a comedy writer for the game Quiplash. Generate a single funny fill-in-the-blank prompt that players will try to answer. The prompt should be surprising and designed to elicit hilarious responses. Return ONLY the prompt text, nothing else. Keep it short (under 15 words).
return `Eres un guionista de comedia para el juego Argument.es (similar a Quiplash). Genera una sola pregunta/frase graciosa de completar espacios en blanco que los jugadores intentarán responder. La pregunta debe ser sorprendente y diseñada para provocar respuestas hilarantes. Devuelve ÚNICAMENTE el texto de la pregunta, nada más. Mantenla corta (menos de 15 palabras).
Use a wide VARIETY of prompt formats. Do NOT always use "The worst thing to..." — mix it up! Here are examples of the range of styles:
Usa una VARIEDAD amplia de formatos. ¡NO siempre uses "Lo peor de..." — varía! Aquí hay ejemplos del rango de estilos:
${examples.map((p) => `- ${p}`).join("\n")}
Come up with something ORIGINAL — don't copy these examples.`;
Crea algo ORIGINAL — no copies estos ejemplos. Responde SIEMPRE en español.`;
}
export async function callGeneratePrompt(model: Model): Promise<string> {
@@ -206,7 +210,7 @@ export async function callGeneratePrompt(model: Model): Promise<string> {
model: openrouter.chat(model.id),
system,
prompt:
"Generate a single original Quiplash prompt. Be creative and don't repeat common patterns.",
"Genera una sola pregunta original para Argument.es. Sé creativo y no repitas patrones comunes. Responde en español.",
});
log("INFO", `prompt:${model.name}`, "Raw response", {
@@ -226,8 +230,8 @@ export async function callGenerateAnswer(
});
const { text, usage, reasoning } = await generateText({
model: openrouter.chat(model.id),
system: `You are playing Quiplash! You'll be given a fill-in-the-blank prompt. Give the FUNNIEST possible answer. Be creative, edgy, unexpected, and concise. Reply with ONLY your answer — no quotes, no explanation, no preamble. Keep it short (under 12 words). Keep it concise and witty.`,
prompt: `Fill in the blank: ${prompt}`,
system: `¡Estás jugando Argument.es! Se te dará una frase de completar espacios en blanco. Da la respuesta MÁS GRACIOSA posible. Sé creativo, atrevido, inesperado y conciso. Responde con ÚNICAMENTE tu respuesta — sin comillas, sin explicación, sin preámbulos. Mantenla corta (menos de 12 palabras). Responde SIEMPRE en español.`,
prompt: `Completa la frase: ${prompt}`,
});
log("INFO", `answer:${model.name}`, "Raw response", {
@@ -251,8 +255,8 @@ export async function callVote(
});
const { text, usage, reasoning } = await generateText({
model: openrouter.chat(voter.id),
system: `You are a judge in a comedy game. You'll see a fill-in-the-blank prompt and two answers. Pick which answer is FUNNIER. You MUST respond with exactly "A" or "B" — nothing else.`,
prompt: `Prompt: "${prompt}"\n\nAnswer A: "${a.answer}"\nAnswer B: "${b.answer}"\n\nWhich is funnier? Reply with just A or B.`,
system: `Eres un juez en un juego de comedia. Verás una frase de completar espacios en blanco y dos respuestas. Elige cuál respuesta es MÁS GRACIOSA. DEBES responder exactamente con "A" o "B" — nada más.`,
prompt: `Pregunta: "${prompt}"\n\nRespuesta A: "${a.answer}"\nRespuesta B: "${b.answer}"\n\n¿Cuál es más graciosa? Responde solo con A o B.`,
});
log("INFO", `vote:${voter.name}`, "Raw response", { rawText: text, usage });
@@ -263,7 +267,7 @@ export async function callVote(
return cleaned.startsWith("A") ? "A" : "B";
}
import { saveRound } from "./db.ts";
import { saveRound, getNextPendingQuestion, markQuestionUsed, persistUserAnswerVotes } from "./db.ts";
// ── Game loop ───────────────────────────────────────────────────────────────
@@ -271,7 +275,7 @@ export async function runGame(
runs: number,
state: GameState,
rerender: () => void,
onViewerVotingStart?: () => void,
onViewerVotingStart?: (round: RoundState) => void,
) {
let startRound = 1;
const lastCompletedRound = state.completed.at(-1);
@@ -324,12 +328,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;
}
@@ -402,7 +415,7 @@ export async function runGame(
round.viewerVotesA = 0;
round.viewerVotesB = 0;
round.viewerVotingEndsAt = Date.now() + 30_000;
onViewerVotingStart?.();
onViewerVotingStart?.(round);
rerender();
await Promise.all([
@@ -471,6 +484,14 @@ export async function runGame(
} else if (votesB > votesA) {
state.scores[contB.name] = (state.scores[contB.name] || 0) + 1;
}
// Viewer vote scoring
const vvA = round.viewerVotesA ?? 0;
const vvB = round.viewerVotesB ?? 0;
if (vvA > vvB) {
state.viewerScores[contA.name] = (state.viewerScores[contA.name] || 0) + 1;
} else if (vvB > vvA) {
state.viewerScores[contB.name] = (state.viewerScores[contB.name] || 0) + 1;
}
rerender();
await new Promise((r) => setTimeout(r, 5000));
@@ -478,6 +499,11 @@ export async function runGame(
continue;
}
// Persist votes for user answers
if (round.userAnswerVotes && round.userAnswers && round.userAnswers.length > 0) {
persistUserAnswerVotes(round.num, round.userAnswerVotes);
}
// Archive round
saveRound(round);
state.completed = [...state.completed, round];

View File

@@ -1,9 +1,9 @@
<!doctype html>
<html lang="en">
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>quipslop History</title>
<title>argument.es — Historial</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link

View File

@@ -104,7 +104,7 @@ function HistoryContestant({
</div>
<div className="history-contestant__votes">
<div className="history-contestant__score" style={{ color }}>
{votes} {votes === 1 ? "vote" : "votes"}
{votes} {votes === 1 ? "voto" : "votos"}
</div>
<div className="history-contestant__voters">
{voters.map((v) => {
@@ -164,7 +164,7 @@ function HistoryCard({ round }: { round: RoundState }) {
<div className="history-card__header">
<div className="history-card__prompt-section">
<div className="history-card__prompter">
Prompted by <ModelName model={round.prompter} />
Pregunta de <ModelName model={round.prompter} />
</div>
<div className="history-card__prompt">{round.prompt}</div>
</div>
@@ -180,7 +180,7 @@ function HistoryCard({ round }: { round: RoundState }) {
<div className="history-contestant__header">
<ModelName model={contA} />
{isAWinner && (
<div className="history-contestant__winner-badge">WINNER</div>
<div className="history-contestant__winner-badge">GANADOR</div>
)}
</div>
<div className="history-contestant__answer">
@@ -191,7 +191,7 @@ function HistoryCard({ round }: { round: RoundState }) {
className="history-contestant__score"
style={{ color: getColor(contA.name) }}
>
{votesA} {votesA === 1 ? "vote" : "votes"}
{votesA} {votesA === 1 ? "voto" : "votos"}
</div>
<div className="history-contestant__voters">
{votersA.map(
@@ -210,7 +210,7 @@ function HistoryCard({ round }: { round: RoundState }) {
{totalViewerVotes > 0 && (
<ViewerVotes
count={round.viewerVotesA ?? 0}
label={`viewer vote${(round.viewerVotesA ?? 0) === 1 ? "" : "s"}`}
label={`voto${(round.viewerVotesA ?? 0) === 1 ? "" : "s"} del público`}
/>
)}
</div>
@@ -221,7 +221,7 @@ function HistoryCard({ round }: { round: RoundState }) {
<div className="history-contestant__header">
<ModelName model={contB} />
{isBWinner && (
<div className="history-contestant__winner-badge">WINNER</div>
<div className="history-contestant__winner-badge">GANADOR</div>
)}
</div>
<div className="history-contestant__answer">
@@ -232,7 +232,7 @@ function HistoryCard({ round }: { round: RoundState }) {
className="history-contestant__score"
style={{ color: getColor(contB.name) }}
>
{votesB} {votesB === 1 ? "vote" : "votes"}
{votesB} {votesB === 1 ? "voto" : "votos"}
</div>
<div className="history-contestant__voters">
{votersB.map(
@@ -251,7 +251,7 @@ function HistoryCard({ round }: { round: RoundState }) {
{totalViewerVotes > 0 && (
<ViewerVotes
count={round.viewerVotesB ?? 0}
label={`viewer vote${(round.viewerVotesB ?? 0) === 1 ? "" : "s"}`}
label={`voto${(round.viewerVotesB ?? 0) === 1 ? "" : "s"} del público`}
/>
)}
</div>
@@ -287,24 +287,24 @@ function App() {
return (
<div className="app">
<a href="/" className="main-logo">
quipslop
argument.es
</a>
<main className="main">
<div className="page-header">
<div className="page-title">Past Rounds</div>
<div className="page-title">Rondas anteriores</div>
<div className="page-links">
<a href="/" className="back-link">
Back to Game
Volver al juego
</a>
</div>
</div>
{loading ? (
<div className="loading">Loading...</div>
<div className="loading">Cargando...</div>
) : error ? (
<div className="error">{error}</div>
) : rounds.length === 0 ? (
<div className="empty">No past rounds found.</div>
<div className="empty">No se encontraron rondas anteriores.</div>
) : (
<>
<div
@@ -326,7 +326,7 @@ function App() {
PREV
</button>
<span className="pagination__info">
Page {page} of {totalPages}
Página {page} de {totalPages}
</span>
<button
className="pagination__btn"

View File

@@ -1,9 +1,9 @@
<!doctype html>
<html lang="en">
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>quipslop</title>
<title>argument.es</title>
<link rel="icon" type="image/svg+xml" href="./public/assets/logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@@ -1,14 +1,11 @@
{
"name": "quipslop",
"name": "argument.es",
"module": "index.ts",
"type": "module",
"private": true,
"scripts": {
"start": "bun server.ts",
"start:cli": "bun quipslop.tsx",
"start:web": "bun --hot server.ts",
"start:stream": "bun ./scripts/stream-browser.ts live",
"start:stream:dryrun": "bun ./scripts/stream-browser.ts dryrun"
"start:dev": "bun --hot server.ts"
},
"devDependencies": {
"@types/bun": "latest",

357
pregunta.css Normal file
View File

@@ -0,0 +1,357 @@
/* ── 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;
align-items: center;
justify-content: center;
}
.pregunta__links a {
color: var(--text-muted);
text-decoration: none;
font-size: 13px;
}
.pregunta__links a:hover {
color: var(--text-dim);
}
.pregunta__links-sep {
color: var(--text-muted);
font-size: 13px;
}
.pregunta__link-btn {
background: none;
border: none;
padding: 0;
color: var(--text-muted);
font-size: 13px;
font-family: var(--sans);
cursor: pointer;
}
.pregunta__link-btn:hover {
color: var(--text-dim);
}
/* ── Header row (logo + credit badge) ─────────────────────────── */
.pregunta__header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pregunta__credit-badge {
font-size: 12px;
font-weight: 600;
color: #4caf7d;
background: rgba(76, 175, 125, 0.12);
border: 1px solid rgba(76, 175, 125, 0.25);
border-radius: 20px;
padding: 4px 12px;
white-space: nowrap;
}
.pregunta__credit-badge--empty {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.03);
border-color: var(--border);
}
/* ── Success banner ────────────────────────────────────────────── */
.pregunta__success {
background: rgba(76, 175, 125, 0.1);
border: 1px solid rgba(76, 175, 125, 0.25);
border-radius: 6px;
padding: 10px 14px;
color: #4caf7d;
font-size: 13px;
font-weight: 500;
}
/* ── Username input ────────────────────────────────────────────── */
.pregunta__input {
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;
transition: border-color 0.15s;
}
.pregunta__input:focus {
outline: none;
border-color: #444;
}
.pregunta__input::placeholder {
color: var(--text-muted);
}
/* ── Tier cards ────────────────────────────────────────────────── */
.tier-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.tier-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 18px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
font-family: var(--sans);
color: var(--text);
transition: border-color 0.15s, background 0.15s;
}
.tier-card:hover {
border-color: #444;
}
.tier-card--selected {
border-color: var(--accent);
background: rgba(217, 119, 87, 0.08);
}
.tier-card__price {
font-size: 20px;
font-weight: 700;
color: var(--accent);
}
.tier-card__label {
font-size: 12px;
font-weight: 500;
color: var(--text);
}
.tier-card__sub {
font-size: 11px;
color: var(--text-muted);
}
/* ── Spinner ───────────────────────────────────────────────────── */
.pregunta__spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: pregunta-spin 0.8s linear infinite;
margin: 8px auto;
}
@keyframes pregunta-spin {
to { transform: rotate(360deg); }
}
/* ── Site footer ───────────────────────────────────────────────── */
.pregunta__footer {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
line-height: 1.9;
text-align: center;
}
.pregunta__footer a {
color: var(--text-dim);
text-decoration: none;
}
.pregunta__footer a:hover {
color: var(--text);
text-decoration: underline;
}

20
pregunta.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Propón una pregunta — argument.es</title>
<link rel="icon" type="image/svg+xml" href="./public/assets/logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="./pregunta.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./pregunta.tsx"></script>
</body>
</html>

457
pregunta.tsx Normal file
View File

@@ -0,0 +1,457 @@
import React, { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";
import "./pregunta.css";
// ── Types & constants ─────────────────────────────────────────────────────────
type CreditInfo = {
token: string;
username: string;
expiresAt: number;
tier: string;
questionsLeft: number | null; // null = unlimited
};
const STORAGE_KEY = "argumentes_credito";
const TIERS = [
{ id: "basico", label: "10 preguntas", sublabel: "30 días", price: "0,99€", maxQuestions: 10 },
{ id: "pro", label: "200 preguntas", sublabel: "30 días", price: "9,99€", maxQuestions: 200 },
{ id: "ilimitado", label: "Ilimitadas", sublabel: "30 días", price: "19,99€", maxQuestions: null },
] as const;
type TierId = (typeof TIERS)[number]["id"];
// ── Helpers ───────────────────────────────────────────────────────────────────
function loadCredit(): CreditInfo | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const c = JSON.parse(raw) as CreditInfo;
if (!c.token || !c.expiresAt || c.expiresAt < Date.now()) {
localStorage.removeItem(STORAGE_KEY);
return null;
}
return c;
} catch {
return null;
}
}
function formatDate(ms: number): string {
return new Date(ms).toLocaleDateString("es-ES", {
day: "numeric",
month: "long",
year: "numeric",
});
}
function badgeText(questionsLeft: number | null): string {
if (questionsLeft === null) return "Preguntas ilimitadas";
if (questionsLeft === 0) return "Sin preguntas restantes";
return `${questionsLeft} pregunta${questionsLeft !== 1 ? "s" : ""} restante${questionsLeft !== 1 ? "s" : ""}`;
}
async function submitRedsysForm(data: {
tpvUrl: string;
merchantParams: string;
signature: string;
signatureVersion: string;
}) {
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();
}
// ── Footer ────────────────────────────────────────────────────────────────────
function SiteFooter() {
return (
<div className="pregunta__footer">
<p>IAs compiten respondiendo preguntas absurdas los jueces votan, también puedes.</p>
<p>
por{" "}
<a href="https://cloudhost.es" target="_blank" rel="noopener noreferrer">
Cloud Host
</a>
{" "} La web simplificada, la nube gestionada
</p>
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
function App() {
const params = new URLSearchParams(window.location.search);
const creditOkOrder = params.get("credito_ok");
const isKo = params.get("ko") === "1";
// Credit state
const [credit, setCredit] = useState<CreditInfo | null>(null);
const [loaded, setLoaded] = useState(false);
// Credit verification (polling after Redsys redirect)
const [verifying, setVerifying] = useState(false);
const [verifyError, setVerifyError] = useState(false);
// Purchase flow
const [selectedTier, setSelectedTier] = useState<TierId | null>(null);
const [username, setUsername] = useState("");
const [buying, setBuying] = useState(false);
const [buyError, setBuyError] = useState<string | null>(null);
// Question submission
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [sent, setSent] = useState(false);
// Load credit from localStorage on mount
useEffect(() => {
setCredit(loadCredit());
setLoaded(true);
}, []);
// Poll for credit activation after Redsys redirect
useEffect(() => {
if (!creditOkOrder || !loaded || credit) return;
setVerifying(true);
let attempts = 0;
const maxAttempts = 15;
async function poll() {
if (attempts >= maxAttempts) {
setVerifying(false);
setVerifyError(true);
return;
}
attempts++;
try {
const res = await fetch(
`/api/credito/estado?order=${encodeURIComponent(creditOkOrder!)}`,
);
if (res.ok) {
const data = (await res.json()) as {
found: boolean;
status?: string;
token?: string;
username?: string;
expiresAt?: number;
tier?: string;
questionsLeft?: number | null;
};
if (data.found && data.status === "active" && data.token && data.expiresAt) {
const newCredit: CreditInfo = {
token: data.token,
username: data.username ?? "",
expiresAt: data.expiresAt,
tier: data.tier ?? "",
questionsLeft: data.questionsLeft ?? null,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(newCredit));
setCredit(newCredit);
setVerifying(false);
history.replaceState(null, "", "/pregunta");
return;
}
}
} catch {
// retry
}
setTimeout(poll, 2000);
}
poll();
}, [creditOkOrder, loaded, credit]);
async function handleBuyCredit(e: React.FormEvent) {
e.preventDefault();
if (!selectedTier) return;
setBuyError(null);
setBuying(true);
try {
const res = await fetch("/api/credito/iniciar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tier: selectedTier, username: username.trim() }),
});
if (!res.ok) throw new Error(await res.text());
await submitRedsysForm(await res.json());
} catch (err) {
setBuyError(err instanceof Error ? err.message : "Error al procesar el pago");
setBuying(false);
}
}
async function handleSubmitQuestion(e: React.FormEvent) {
e.preventDefault();
if (!credit) return;
setSubmitError(null);
setSubmitting(true);
try {
const res = await fetch("/api/pregunta/enviar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: text.trim(), token: credit.token }),
});
if (!res.ok) {
const msg = await res.text();
if (res.status === 401) {
localStorage.removeItem(STORAGE_KEY);
setCredit(null);
throw new Error("Tu acceso ha expirado o se han agotado las preguntas.");
}
throw new Error(msg || `Error ${res.status}`);
}
const data = await res.json() as { ok: boolean; questionsLeft: number | null };
// Update questionsLeft in state and localStorage
const updated: CreditInfo = { ...credit, questionsLeft: data.questionsLeft };
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
setCredit(updated);
setText("");
setSent(true);
setTimeout(() => setSent(false), 4000);
} catch (err) {
setSubmitError(err instanceof Error ? err.message : "Error al enviar");
} finally {
setSubmitting(false);
}
}
// ── Loading ───────────────────────────────────────────────────────────────
if (!loaded) {
return (
<div className="pregunta">
<div className="pregunta__panel">
<a href="/" className="pregunta__logo">
<img src="/assets/logo.svg" alt="argument.es" />
</a>
</div>
</div>
);
}
// ── Payment failed ────────────────────────────────────────────────────────
if (isKo) {
return (
<div className="pregunta">
<div className="pregunta__panel">
<a href="/" className="pregunta__logo">
<img src="/assets/logo.svg" alt="argument.es" />
</a>
<h1>Pago cancelado</h1>
<p className="pregunta__sub">
El pago no se completó. Tu acceso no ha sido activado.
</p>
<a href="/pregunta" className="pregunta__btn">Intentar de nuevo</a>
<div className="pregunta__links">
<a href="/">Volver al juego</a>
</div>
<SiteFooter />
</div>
</div>
);
}
// ── Verifying payment ─────────────────────────────────────────────────────
if (verifying || (creditOkOrder && !credit)) {
return (
<div className="pregunta">
<div className="pregunta__panel">
<a href="/" className="pregunta__logo">
<img src="/assets/logo.svg" alt="argument.es" />
</a>
<h1>Verificando tu pago</h1>
<p className="pregunta__sub">
{verifyError
? "No se pudo confirmar el pago. Si se completó, espera unos segundos y recarga."
: "Esto puede tardar unos segundos."}
</p>
{verifyError ? (
<a href="/pregunta" className="pregunta__btn">Volver</a>
) : (
<div className="pregunta__spinner" />
)}
<SiteFooter />
</div>
</div>
);
}
// ── Active credit — question form ─────────────────────────────────────────
if (credit) {
const exhausted = credit.questionsLeft !== null && credit.questionsLeft <= 0;
return (
<div className="pregunta">
<div className="pregunta__panel">
<div className="pregunta__header-row">
<a href="/" className="pregunta__logo">
<img src="/assets/logo.svg" alt="argument.es" />
</a>
<div className={`pregunta__credit-badge ${exhausted ? "pregunta__credit-badge--empty" : ""}`}>
{badgeText(credit.questionsLeft)}
</div>
</div>
<h1>Hola, {credit.username}</h1>
<p className="pregunta__sub">
Acceso activo hasta el {formatDate(credit.expiresAt)}.{" "}
{exhausted
? "Has agotado tus preguntas para este plan."
: "Envía todas las preguntas que quieras."}
</p>
{sent && (
<div className="pregunta__success">
¡Pregunta enviada! Se usará en el próximo sorteo.
</div>
)}
{!exhausted && (
<form onSubmit={handleSubmitQuestion} className="pregunta__form">
<label htmlFor="pregunta-text" className="pregunta__label">
Tu pregunta (frase de completar)
</label>
<textarea
id="pregunta-text"
className="pregunta__textarea"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder='Ejemplo: "La peor cosa que puedes encontrar en ___"'
maxLength={200}
required
rows={3}
autoFocus
/>
<div className="pregunta__hint">{text.length}/200 · mínimo 10</div>
{submitError && <div className="pregunta__error">{submitError}</div>}
<button
type="submit"
className="pregunta__submit"
disabled={submitting || text.trim().length < 10}
>
{submitting ? "Enviando…" : "Enviar pregunta"}
</button>
</form>
)}
{exhausted && (
<a href="/pregunta" className="pregunta__btn" onClick={() => {
localStorage.removeItem(STORAGE_KEY);
}}>
Comprar nuevo plan
</a>
)}
<div className="pregunta__links">
<a href="/">Ver el juego</a>
<span className="pregunta__links-sep">·</span>
<button
className="pregunta__link-btn"
onClick={() => {
localStorage.removeItem(STORAGE_KEY);
setCredit(null);
}}
>
Cerrar sesión
</button>
</div>
<SiteFooter />
</div>
</div>
);
}
// ── No credit — tier selection ────────────────────────────────────────────
const tierInfo = selectedTier ? TIERS.find((t) => t.id === selectedTier) : null;
return (
<div className="pregunta">
<div className="pregunta__panel">
<a href="/" className="pregunta__logo">
<img src="/assets/logo.svg" alt="argument.es" />
</a>
<h1>Propón preguntas al juego</h1>
<p className="pregunta__sub">
Compra acceso por 30 días y envía preguntas ilimitadas o un paquete.
Las mejores se usan en lugar de las generadas por IA y
aparecerás en el marcador de Jugadores.
</p>
<div className="tier-cards">
{TIERS.map((tier) => (
<button
key={tier.id}
type="button"
className={`tier-card ${selectedTier === tier.id ? "tier-card--selected" : ""}`}
onClick={() => setSelectedTier(tier.id)}
>
<div className="tier-card__price">{tier.price}</div>
<div className="tier-card__label">{tier.label}</div>
<div className="tier-card__sub">{tier.sublabel}</div>
</button>
))}
</div>
{selectedTier && (
<form onSubmit={handleBuyCredit} className="pregunta__form">
<label htmlFor="username" className="pregunta__label">
Tu nombre en el marcador
</label>
<input
id="username"
type="text"
className="pregunta__input"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Ej: Malin"
maxLength={30}
required
autoFocus
/>
{buyError && <div className="pregunta__error">{buyError}</div>}
<button
type="submit"
className="pregunta__submit"
disabled={buying || !username.trim()}
>
{buying
? "Redirigiendo…"
: `Pagar ${tierInfo?.price}${tierInfo?.label}`}
</button>
</form>
)}
<div className="pregunta__links">
<a href="/">Volver al juego</a>
</div>
<SiteFooter />
</div>
</div>
);
}
const root = createRoot(document.getElementById("root")!);
root.render(<App />);

1726
prompts.ts

File diff suppressed because it is too large Load Diff

View File

@@ -224,6 +224,7 @@ function Game({ runs }: { runs: number }) {
completed: [],
active: null,
scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
viewerScores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
done: false,
isPaused: false,
generation: 0,

110
redsys.ts Normal file
View File

@@ -0,0 +1,110 @@
import { createCipheriv, createHmac } from "node:crypto";
// ── Key derivation ────────────────────────────────────────────────────────────
// Redsys HMAC_SHA256_V1: derive a per-order signature key by 3DES-CBC encrypting
// the padded order ID with the merchant secret key.
function deriveKey(secretKeyBase64: string, orderId: string): Buffer {
const rawKey = Buffer.from(secretKeyBase64, "base64");
// 3DES-CBC requires exactly 24 bytes
const key24 = Buffer.alloc(24);
rawKey.copy(key24, 0, 0, Math.min(rawKey.length, 24));
// Pad order ID to a multiple of 8 bytes with zeros
const orderBuf = Buffer.from(orderId, "ascii");
const remainder = orderBuf.length % 8;
const paddedOrder =
remainder === 0 ? orderBuf : Buffer.concat([orderBuf, Buffer.alloc(8 - remainder)]);
// 3DES-CBC with 8-byte zero IV, no auto-padding
const iv = Buffer.alloc(8, 0);
const cipher = createCipheriv("des-ede3-cbc", key24, iv);
cipher.setAutoPadding(false);
return Buffer.concat([cipher.update(paddedOrder), cipher.final()]);
}
function normalizeBase64(s: string): string {
return s.replace(/-/g, "+").replace(/_/g, "/").replace(/=/g, "");
}
// ── Public API ────────────────────────────────────────────────────────────────
export function encodeParams(params: Record<string, string>): string {
return Buffer.from(JSON.stringify(params)).toString("base64");
}
export function decodeParams(merchantParams: string): Record<string, string> {
// Redsys sends back URL-safe Base64; normalize before decoding
const normalized = merchantParams.replace(/-/g, "+").replace(/_/g, "/");
return JSON.parse(Buffer.from(normalized, "base64").toString("utf8"));
}
export function sign(secretKeyBase64: string, orderId: string, data: string): string {
const derivedKey = deriveKey(secretKeyBase64, orderId);
return createHmac("sha256", derivedKey).update(data).digest("base64");
}
export function isPaymentApproved(decodedParams: Record<string, string>): boolean {
const code = parseInt(decodedParams["Ds_Response"] ?? "9999", 10);
return Number.isFinite(code) && code >= 0 && code <= 99;
}
export type RedsysConfig = {
secretKey: string;
merchantCode: string;
terminal: string;
isTest: boolean;
orderId: string;
amount: number; // in cents
urlOk: string;
urlKo: string;
merchantUrl: string;
productDescription?: string;
};
export function buildPaymentForm(config: RedsysConfig): {
tpvUrl: string;
merchantParams: string;
signature: string;
signatureVersion: string;
} {
const tpvUrl = config.isTest
? "https://sis-t.redsys.es:25443/sis/realizarPago"
: "https://sis.redsys.es/sis/realizarPago";
const params: Record<string, string> = {
DS_MERCHANT_AMOUNT: String(config.amount),
DS_MERCHANT_ORDER: config.orderId,
DS_MERCHANT_MERCHANTCODE: config.merchantCode,
DS_MERCHANT_CURRENCY: "978",
DS_MERCHANT_TRANSACTIONTYPE: "0",
DS_MERCHANT_TERMINAL: config.terminal,
DS_MERCHANT_URLOK: config.urlOk,
DS_MERCHANT_URLKO: config.urlKo,
DS_MERCHANT_MERCHANTURL: config.merchantUrl,
DS_MERCHANT_CONSUMERLANGUAGE: "001",
};
if (config.productDescription) {
params["DS_MERCHANT_PRODUCTDESCRIPTION"] = config.productDescription;
}
const merchantParams = encodeParams(params);
const signature = sign(config.secretKey, config.orderId, merchantParams);
return { tpvUrl, merchantParams, signature, signatureVersion: "HMAC_SHA256_V1" };
}
export function verifyNotification(
secretKeyBase64: string,
merchantParams: string,
receivedSignature: string,
): boolean {
try {
const decoded = decodeParams(merchantParams);
const orderId = decoded["Ds_Order"] ?? decoded["DS_MERCHANT_ORDER"] ?? "";
if (!orderId) return false;
const expectedSig = sign(secretKeyBase64, orderId, merchantParams);
return normalizeBase64(expectedSig) === normalizeBase64(receivedSignature);
} catch {
return false;
}
}

View File

@@ -1,385 +0,0 @@
import puppeteer from "puppeteer";
type Mode = "live" | "dryrun";
type SinkWriter = {
write(chunk: Uint8Array): number;
end(error?: Error): number;
};
function parsePositiveInt(value: string | undefined, fallback: number): number {
const parsed = Number.parseInt(value ?? "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function usage(): never {
console.error("Usage: bun scripts/stream-browser.ts <live|dryrun>");
console.error("Required for live mode: TWITCH_STREAM_KEY");
process.exit(1);
}
function resolveMode(value: string | undefined): Mode {
if (value === "live" || value === "dryrun") return value;
return usage();
}
const mode = resolveMode(process.argv[2]);
const streamFps = parsePositiveInt(process.env.STREAM_FPS, 30);
const captureBitrate = parsePositiveInt(process.env.STREAM_CAPTURE_BITRATE, 12_000_000);
const targetSize = process.env.STREAM_TARGET_SIZE ?? "1920x1080";
const targetParts = targetSize.split("x");
const targetWidth = targetParts[0] ?? "1920";
const targetHeight = targetParts[1] ?? "1080";
const videoBitrate = process.env.STREAM_VIDEO_BITRATE ?? "6000k";
const maxrate = process.env.STREAM_MAXRATE ?? "6000k";
const bufsize = process.env.STREAM_BUFSIZE ?? "12000k";
const gop = String(parsePositiveInt(process.env.STREAM_GOP, 60));
const audioBitrate = process.env.STREAM_AUDIO_BITRATE ?? "160k";
const streamKey = process.env.TWITCH_STREAM_KEY;
const serverPort = process.env.STREAM_APP_PORT ?? "5109";
const broadcastUrl = process.env.BROADCAST_URL ?? `http://127.0.0.1:${serverPort}/broadcast`;
const redactionTokens = [
broadcastUrl,
streamKey,
streamKey ? encodeURIComponent(streamKey) : undefined,
streamKey ? `rtmp://live.twitch.tv/app/${streamKey}` : undefined,
].filter((token): token is string => Boolean(token));
const redactionWindow = Math.max(1, ...redactionTokens.map((token) => token.length));
function redactSensitive(value: string): string {
let output = value;
for (const token of redactionTokens) {
output = output.split(token).join("[REDACTED]");
}
return output;
}
if (mode === "live" && !streamKey) {
console.error("TWITCH_STREAM_KEY is not set.");
process.exit(1);
}
async function assertBroadcastReachable(url: string) {
const timeoutMs = 5_000;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Cannot reach broadcast page (${redactSensitive(detail)}). Start the app server first (bun run start or bun run start:web).`,
);
} finally {
clearTimeout(timeout);
}
}
function buildFfmpegArgs(currentMode: Mode): string[] {
const args = [
"-hide_banner",
"-loglevel",
"warning",
"-fflags",
"+genpts",
"-f",
"webm",
"-i",
"pipe:0",
"-f",
"lavfi",
"-i",
"anullsrc=channel_layout=stereo:sample_rate=44100",
"-map",
"0:v:0",
"-map",
"1:a:0",
"-vf",
`scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease,pad=${targetWidth}:${targetHeight}:(ow-iw)/2:(oh-ih)/2`,
"-c:v",
"libx264",
"-preset",
"veryfast",
"-tune",
"zerolatency",
"-pix_fmt",
"yuv420p",
"-b:v",
videoBitrate,
"-maxrate",
maxrate,
"-bufsize",
bufsize,
"-g",
gop,
"-keyint_min",
gop,
"-sc_threshold",
"0",
"-c:a",
"aac",
"-b:a",
audioBitrate,
"-ar",
"44100",
"-ac",
"2",
];
if (currentMode === "live") {
args.push("-f", "flv", `rtmp://live.twitch.tv/app/${streamKey}`);
return args;
}
args.push("-f", "mpegts", "pipe:1");
return args;
}
async function pipeReadableToSink(
readable: ReadableStream<Uint8Array>,
sink: SinkWriter,
) {
const reader = readable.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) sink.write(value);
}
} finally {
sink.end();
}
}
async function pipeReadableToRedactedStderr(readable: ReadableStream<Uint8Array>) {
const reader = readable.getReader();
const decoder = new TextDecoder();
let carry = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const combined = carry + chunk;
if (combined.length <= redactionWindow) {
carry = combined;
continue;
}
const flushUntil = combined.length - (redactionWindow - 1);
const safeOutput = combined.slice(0, flushUntil);
carry = combined.slice(flushUntil);
if (safeOutput.length > 0) {
process.stderr.write(redactSensitive(safeOutput));
}
}
const trailing = carry + decoder.decode();
if (trailing.length > 0) {
process.stderr.write(redactSensitive(trailing));
}
} finally {
reader.releaseLock();
}
}
async function main() {
await assertBroadcastReachable(broadcastUrl);
const ffmpegArgs = buildFfmpegArgs(mode);
const ffmpeg = Bun.spawn(["ffmpeg", ...ffmpegArgs], {
stdin: "pipe",
stdout: mode === "dryrun" ? "pipe" : "inherit",
stderr: "pipe",
});
if (ffmpeg.stderr) {
void pipeReadableToRedactedStderr(ffmpeg.stderr);
}
let ffmpegWritable = true;
let ffplay: Bun.Subprocess | null = null;
let ffplayPump: Promise<void> | null = null;
if (mode === "dryrun") {
ffplay = Bun.spawn(
[
"ffplay",
"-hide_banner",
"-fflags",
"nobuffer",
"-flags",
"low_delay",
"-framedrop",
"-i",
"pipe:0",
],
{
stdin: "pipe",
stdout: "inherit",
stderr: "inherit",
},
);
const stdout = ffmpeg.stdout;
if (!stdout || !ffplay.stdin) {
throw new Error("Failed to pipe ffmpeg output into ffplay.");
}
if (typeof ffplay.stdin === "number") {
throw new Error("ffplay stdin is not writable.");
}
ffplayPump = pipeReadableToSink(stdout, ffplay.stdin as SinkWriter);
}
let firstChunkResolve: (() => void) | null = null;
let firstChunkReject: ((error: Error) => void) | null = null;
const firstChunk = new Promise<void>((resolve, reject) => {
firstChunkResolve = resolve;
firstChunkReject = reject;
});
let shutdown: (() => Promise<void>) | null = null;
const chunkServer = Bun.serve({
port: 0,
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/chunks" && server.upgrade(req)) {
return;
}
return new Response("Not found", { status: 404 });
},
websocket: {
message(_ws, message) {
if (!ffmpegWritable || !ffmpeg.stdin || typeof ffmpeg.stdin === "number") {
return;
}
if (typeof message === "string") return;
let chunk: Uint8Array | null = null;
if (message instanceof ArrayBuffer) {
chunk = new Uint8Array(message);
} else if (ArrayBuffer.isView(message)) {
chunk = new Uint8Array(
message.buffer,
message.byteOffset,
message.byteLength,
);
}
if (!chunk) return;
try {
ffmpeg.stdin.write(chunk);
firstChunkResolve?.();
firstChunkResolve = null;
firstChunkReject = null;
} catch (error) {
ffmpegWritable = false;
const detail = error instanceof Error ? error : new Error(String(error));
firstChunkReject?.(detail);
firstChunkResolve = null;
firstChunkReject = null;
void shutdown?.();
}
},
},
});
const browser = await puppeteer.launch({
headless: true,
args: [
"--autoplay-policy=no-user-gesture-required",
"--disable-background-timer-throttling",
"--disable-renderer-backgrounding",
"--disable-backgrounding-occluded-windows",
"--allow-running-insecure-content",
"--disable-features=LocalNetworkAccessChecks",
],
});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
page.on("console", (msg) => {
if (process.env.STREAM_DEBUG === "1") {
console.log(`[broadcast] ${msg.type()}: ${redactSensitive(msg.text())}`);
}
});
const captureUrl = new URL(broadcastUrl);
captureUrl.searchParams.set("sink", `ws://127.0.0.1:${chunkServer.port}/chunks`);
captureUrl.searchParams.set("captureFps", String(streamFps));
captureUrl.searchParams.set("captureBitrate", String(captureBitrate));
await page.goto(captureUrl.toString(), { waitUntil: "networkidle2" });
await page.waitForSelector("#broadcast-canvas", { timeout: 10_000 });
const firstChunkTimer = setTimeout(() => {
firstChunkReject?.(
new Error("No media chunks received from headless browser within 10s."),
);
}, 10_000);
await firstChunk.finally(() => clearTimeout(firstChunkTimer));
console.log(`Streaming broadcast in ${mode} mode`);
let shuttingDown = false;
shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
ffmpegWritable = false;
try {
chunkServer.stop(true);
} catch {}
try {
await browser.close();
} catch {}
try {
ffmpeg.stdin?.end();
} catch {}
try {
ffmpeg.kill();
} catch {}
if (ffplay) {
try {
if (ffplay.stdin && typeof ffplay.stdin !== "number") {
ffplay.stdin.end();
}
} catch {}
try {
ffplay.kill();
} catch {}
}
};
const ffmpegExit = ffmpeg.exited.then((code) => {
ffmpegWritable = false;
void shutdown?.();
return code;
});
process.on("SIGINT", () => {
void shutdown?.();
});
process.on("SIGTERM", () => {
void shutdown?.();
});
const exitCode = await ffmpegExit;
if (ffplayPump) {
await ffplayPump.catch(() => {
// Ignore downstream pipe failures on shutdown.
});
}
if (ffplay) {
await ffplay.exited;
}
await shutdown?.();
if (exitCode !== 0) {
process.exit(exitCode);
}
}
main().catch((error) => {
const detail = error instanceof Error ? error.message : String(error);
console.error(redactSensitive(detail));
process.exit(1);
});

604
server.ts
View File

@@ -4,7 +4,14 @@ import indexHtml from "./index.html";
import historyHtml from "./history.html";
import adminHtml from "./admin.html";
import broadcastHtml from "./broadcast.html";
import { clearAllRounds, getRounds, getAllRounds } from "./db.ts";
import preguntaHtml from "./pregunta.html";
import {
clearAllRounds, getRounds, getAllRounds,
createPendingCredit, activateCredit, getCreditByOrder,
submitUserAnswer, insertAdminAnswer,
getPlayerScores, persistUserAnswerVotes,
} from "./db.ts";
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
import {
MODELS,
LOG_FILE,
@@ -30,6 +37,7 @@ if (!process.env.OPENROUTER_API_KEY) {
const allRounds = getAllRounds();
const initialScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
const initialViewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
let initialCompleted: RoundState[] = [];
if (allRounds.length > 0) {
@@ -43,6 +51,15 @@ if (allRounds.length > 0) {
(initialScores[round.contestants[1].name] || 0) + 1;
}
}
const vvA = round.viewerVotesA ?? 0;
const vvB = round.viewerVotesB ?? 0;
if (vvA > vvB) {
initialViewerScores[round.contestants[0].name] =
(initialViewerScores[round.contestants[0].name] || 0) + 1;
} else if (vvB > vvA) {
initialViewerScores[round.contestants[1].name] =
(initialViewerScores[round.contestants[1].name] || 0) + 1;
}
}
const lastRound = allRounds[allRounds.length - 1];
if (lastRound) {
@@ -54,8 +71,10 @@ const gameState: GameState = {
completed: initialCompleted,
active: null,
scores: initialScores,
viewerScores: initialViewerScores,
done: false,
isPaused: false,
autoPaused: false,
generation: 0,
};
@@ -74,6 +93,9 @@ const ADMIN_LIMIT_PER_MIN = parsePositiveInt(
);
const MAX_WS_GLOBAL = parsePositiveInt(process.env.MAX_WS_GLOBAL, 100_000);
const MAX_WS_PER_IP = parsePositiveInt(process.env.MAX_WS_PER_IP, 8);
const MAX_WS_NEW_PER_SEC = parsePositiveInt(process.env.MAX_WS_NEW_PER_SEC, 50);
let wsNewConnections = 0;
let wsNewConnectionsResetAt = Date.now() + 1000;
const MAX_HISTORY_PAGE = parsePositiveInt(
process.env.MAX_HISTORY_PAGE,
100_000,
@@ -87,7 +109,18 @@ const MAX_HISTORY_CACHE_KEYS = parsePositiveInt(
process.env.MAX_HISTORY_CACHE_KEYS,
500,
);
const ADMIN_COOKIE = "quipslop_admin";
const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt(
process.env.VIEWER_VOTE_BROADCAST_DEBOUNCE_MS,
250,
);
const AUTOPAUSE_DELAY_MS = parsePositiveInt(process.env.AUTOPAUSE_DELAY_MS, 60_000);
const ADMIN_COOKIE = "argumentes_admin";
const CREDIT_TIERS: Record<string, { amount: number; label: string; maxAnswers: number }> = {
basico: { amount: 99, label: "10 respuestas", maxAnswers: 10 },
pro: { amount: 999, label: "300 respuestas", maxAnswers: 300 },
full: { amount: 1999, label: "1000 respuestas", maxAnswers: 1000 },
};
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
const requestWindows = new Map<string, number[]>();
@@ -101,21 +134,41 @@ function parsePositiveInt(value: string | undefined, fallback: number): number {
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function getClientIp(req: Request, server: Bun.Server<WsData>): string {
// Railway's edge proxy strips client-provided X-Real-IP and sets the actual
// client IP. All traffic goes through the edge proxy — it cannot be bypassed.
// As a fallback, use the rightmost X-Forwarded-For value (the one Railway
// appends), then Bun's requestIP (which sees the proxy IP on Railway).
const realIp = req.headers.get("x-real-ip")?.trim();
if (realIp) return realIp;
function isPrivateIp(ip: string): boolean {
const v4 = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
if (v4 === "127.0.0.1" || ip === "::1") return true;
if (v4.startsWith("10.")) return true;
if (v4.startsWith("192.168.")) return true;
// CGNAT range (RFC 6598) — used by Railway's internal proxy
if (v4.startsWith("100.")) {
const second = parseInt(v4.split(".")[1] ?? "", 10);
if (second >= 64 && second <= 127) return true;
}
if (ip.startsWith("fc") || ip.startsWith("fd")) return true;
if (v4.startsWith("172.")) {
const second = parseInt(v4.split(".")[1] ?? "", 10);
if (second >= 16 && second <= 31) return true;
}
return false;
}
const xff = req.headers.get("x-forwarded-for");
if (xff) {
const rightmost = xff.split(",").at(-1)?.trim();
if (rightmost) return rightmost;
function getClientIp(req: Request, server: Bun.Server<WsData>): string {
const socketIp = server.requestIP(req)?.address ?? "unknown";
// Only trust proxy headers when the direct connection comes from
// a private IP (i.e. Railway's edge proxy). Direct public connections
// cannot spoof their IP this way.
if (socketIp !== "unknown" && isPrivateIp(socketIp)) {
const xff = req.headers.get("x-forwarded-for");
if (xff) {
const rightmost = xff.split(",").at(-1)?.trim();
if (rightmost && !isPrivateIp(rightmost)) {
return rightmost.startsWith("::ffff:") ? rightmost.slice(7) : rightmost;
}
}
}
return server.requestIP(req)?.address ?? "unknown";
return socketIp.startsWith("::ffff:") ? socketIp.slice(7) : socketIp;
}
function isRateLimited(key: string, limit: number, windowMs: number): boolean {
@@ -226,10 +279,64 @@ function setHistoryCache(key: string, body: string, expiresAt: number) {
historyCache.set(key, { body, expiresAt });
}
type ViewerVoteSide = "A" | "B";
function applyViewerVote(voterId: string, side: ViewerVoteSide): boolean {
const round = gameState.active;
if (!round || round.phase !== "voting") return false;
if (!round.viewerVotingEndsAt || Date.now() > round.viewerVotingEndsAt) {
return false;
}
const previousVote = viewerVoters.get(voterId);
if (previousVote === side) return false;
// Undo previous vote if this viewer switched sides.
if (previousVote === "A") {
round.viewerVotesA = Math.max(0, (round.viewerVotesA ?? 0) - 1);
} else if (previousVote === "B") {
round.viewerVotesB = Math.max(0, (round.viewerVotesB ?? 0) - 1);
}
viewerVoters.set(voterId, side);
if (side === "A") {
round.viewerVotesA = (round.viewerVotesA ?? 0) + 1;
} else {
round.viewerVotesB = (round.viewerVotesB ?? 0) + 1;
}
return true;
}
// ── Auto-pause ───────────────────────────────────────────────────────────────
let autoPauseTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleAutoPause() {
if (autoPauseTimer) return;
autoPauseTimer = setTimeout(() => {
autoPauseTimer = null;
if (clients.size === 0 && !gameState.isPaused) {
gameState.isPaused = true;
gameState.autoPaused = true;
broadcast();
log("INFO", "server", "Auto-paused game — no viewers");
}
}, AUTOPAUSE_DELAY_MS);
}
function cancelAutoPause() {
if (autoPauseTimer) {
clearTimeout(autoPauseTimer);
autoPauseTimer = null;
}
}
// ── WebSocket clients ───────────────────────────────────────────────────────
const clients = new Set<ServerWebSocket<WsData>>();
const viewerVoters = new Map<string, "A" | "B">();
const userAnswerVoters = new Map<string, string>(); // voterIp → voted-for username
let viewerVoteBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleViewerVoteBroadcast() {
@@ -237,7 +344,7 @@ function scheduleViewerVoteBroadcast() {
viewerVoteBroadcastTimer = setTimeout(() => {
viewerVoteBroadcastTimer = null;
broadcast();
}, 5_000);
}, VIEWER_VOTE_BROADCAST_DEBOUNCE_MS);
}
function getClientState() {
@@ -245,8 +352,10 @@ function getClientState() {
active: gameState.active,
lastCompleted: gameState.completed.at(-1) ?? null,
scores: gameState.scores,
viewerScores: gameState.viewerScores,
done: gameState.done,
isPaused: gameState.isPaused,
autoPaused: gameState.autoPaused,
generation: gameState.generation,
};
}
@@ -301,6 +410,7 @@ const server = Bun.serve<WsData>({
"/history": historyHtml,
"/admin": adminHtml,
"/broadcast": broadcastHtml,
"/pregunta": preguntaHtml,
},
async fetch(req, server) {
const url = new URL(req.url);
@@ -321,6 +431,384 @@ const server = Bun.serve<WsData>({
return new Response("ok", { status: 200 });
}
if (url.pathname === "/api/vote") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "POST" },
});
}
let side: string = "";
try {
const body = await req.json();
side = String((body as Record<string, unknown>).side ?? "");
} catch {
return new Response("Invalid JSON body", { status: 400 });
}
if (side !== "A" && side !== "B") {
return new Response("Invalid side", { status: 400 });
}
const applied = applyViewerVote(ip, side as ViewerVoteSide);
if (applied) {
scheduleViewerVoteBroadcast();
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
});
}
if (url.pathname === "/api/vote/respuesta") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "POST" },
});
}
let username = "";
try {
const body = await req.json();
username = String((body as Record<string, unknown>).username ?? "").trim();
} catch {
return new Response("Invalid JSON body", { status: 400 });
}
const round = gameState.active;
const votingOpen =
round?.phase === "voting" &&
round.viewerVotingEndsAt &&
Date.now() <= round.viewerVotingEndsAt;
if (!votingOpen || !round) {
return new Response(JSON.stringify({ ok: false, reason: "voting closed" }), {
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
const answers = round.userAnswers ?? [];
if (!answers.some((a) => a.username === username)) {
return new Response("Unknown answer", { status: 400 });
}
const prevVote = userAnswerVoters.get(ip);
if (prevVote !== username) {
// Undo previous vote
if (prevVote) {
round.userAnswerVotes = { ...(round.userAnswerVotes ?? {}) };
round.userAnswerVotes[prevVote] = Math.max(0, (round.userAnswerVotes[prevVote] ?? 0) - 1);
}
userAnswerVoters.set(ip, username);
round.userAnswerVotes = { ...(round.userAnswerVotes ?? {}) };
round.userAnswerVotes[username] = (round.userAnswerVotes[username] ?? 0) + 1;
scheduleViewerVoteBroadcast();
}
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
if (url.pathname === "/api/pregunta/iniciar") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "POST" },
});
}
const secretKey = process.env.REDSYS_SECRET_KEY;
const merchantCode = process.env.REDSYS_MERCHANT_CODE;
if (!secretKey || !merchantCode) {
return new Response("Pagos no configurados", { status: 503 });
}
if (isRateLimited(`pregunta:${ip}`, 5, WINDOW_MS)) {
return new Response("Too Many Requests", { status: 429 });
}
let text = "";
try {
const body = await req.json();
text = String((body as Record<string, unknown>).text ?? "").trim();
} catch {
return new Response("Invalid JSON body", { status: 400 });
}
if (text.length < 10 || text.length > 200) {
return new Response("La pregunta debe tener entre 10 y 200 caracteres", { status: 400 });
}
const orderId = String(Date.now()).slice(-12);
createPendingQuestion(text, orderId);
const isTest = process.env.REDSYS_TEST !== "false";
const terminal = process.env.REDSYS_TERMINAL ?? "1";
const baseUrl =
process.env.PUBLIC_URL?.replace(/\/$/, "") ??
`${url.protocol}//${url.host}`;
const form = buildPaymentForm({
secretKey,
merchantCode,
terminal,
isTest,
orderId,
amount: 100,
urlOk: `${baseUrl}/pregunta?ok=1`,
urlKo: `${baseUrl}/pregunta?ko=1`,
merchantUrl: `${baseUrl}/api/redsys/notificacion`,
productDescription: "Pregunta argument.es",
});
log("INFO", "pregunta", "Payment initiated", { orderId, ip });
return new Response(JSON.stringify({ ok: true, ...form }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
});
}
if (url.pathname === "/api/redsys/notificacion") {
// Always return 200 so Redsys doesn't retry; log errors internally.
const secretKey = process.env.REDSYS_SECRET_KEY;
if (!secretKey || req.method !== "POST") {
return new Response("ok", { status: 200 });
}
let merchantParams = "";
let receivedSignature = "";
try {
const body = await req.text();
const fd = new URLSearchParams(body);
merchantParams = fd.get("Ds_MerchantParameters") ?? "";
receivedSignature = fd.get("Ds_Signature") ?? "";
} catch {
return new Response("ok", { status: 200 });
}
if (!verifyNotification(secretKey, merchantParams, receivedSignature)) {
log("WARN", "redsys", "Invalid notification signature");
return new Response("ok", { status: 200 });
}
const decoded = decodeParams(merchantParams);
if (isPaymentApproved(decoded)) {
const orderId = decoded["Ds_Order"] ?? "";
if (orderId) {
// Try question order first, then credit order
const markedQuestion = markQuestionPaid(orderId);
if (markedQuestion) {
log("INFO", "redsys", "Question marked as paid", { orderId });
} else {
const credit = getCreditByOrder(orderId);
if (credit && credit.status === "pending") {
const tierInfo = CREDIT_TIERS[credit.tier];
if (tierInfo) {
// No time limit — set expiry 10 years out so existing checks pass
const expiresAt = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;
const activated = activateCredit(orderId, expiresAt);
if (activated) {
log("INFO", "redsys", "Credit activated", {
orderId,
username: activated.username,
tier: credit.tier,
});
}
}
}
}
}
} else {
log("INFO", "redsys", "Payment not approved", {
response: decoded["Ds_Response"],
});
}
return new Response("ok", { status: 200 });
}
if (url.pathname === "/api/credito/iniciar") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "POST" },
});
}
const secretKey = process.env.REDSYS_SECRET_KEY;
const merchantCode = process.env.REDSYS_MERCHANT_CODE;
if (!secretKey || !merchantCode) {
return new Response("Pagos no configurados", { status: 503 });
}
if (isRateLimited(`credito:${ip}`, 5, WINDOW_MS)) {
return new Response("Too Many Requests", { status: 429 });
}
let tier = "";
let username = "";
try {
const body = await req.json();
tier = String((body as Record<string, unknown>).tier ?? "").trim();
username = String((body as Record<string, unknown>).username ?? "").trim();
} catch {
return new Response("Invalid JSON body", { status: 400 });
}
const tierInfo = CREDIT_TIERS[tier];
if (!tierInfo) {
return new Response("Tier inválido (basico | pro | full)", { status: 400 });
}
if (!username || username.length < 1 || username.length > 30) {
return new Response("El nombre debe tener entre 1 y 30 caracteres", { status: 400 });
}
const orderId = String(Date.now()).slice(-12);
createPendingCredit(username, orderId, tier, tierInfo.maxAnswers);
const isTest = process.env.REDSYS_TEST !== "false";
const terminal = process.env.REDSYS_TERMINAL ?? "1";
const baseUrl =
process.env.PUBLIC_URL?.replace(/\/$/, "") ??
`${url.protocol}//${url.host}`;
const form = buildPaymentForm({
secretKey,
merchantCode,
terminal,
isTest,
orderId,
amount: tierInfo.amount,
urlOk: `${baseUrl}/?credito_ok=${orderId}`,
urlKo: `${baseUrl}/?ko=1`,
merchantUrl: `${baseUrl}/api/redsys/notificacion`,
productDescription: `argument.es — ${tierInfo.label}`,
});
log("INFO", "credito", "Credit purchase initiated", {
orderId,
tier,
ip,
username: username.slice(0, 10),
});
return new Response(JSON.stringify({ ok: true, ...form }), {
status: 200,
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
if (url.pathname === "/api/credito/estado") {
const orderId = url.searchParams.get("order") ?? "";
if (!orderId) {
return new Response("Missing order", { status: 400 });
}
const credit = getCreditByOrder(orderId);
if (!credit) {
return new Response(JSON.stringify({ found: false }), {
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
const answersLeft = credit.maxQuestions === null
? 0
: credit.maxQuestions - credit.questionsUsed;
return new Response(
JSON.stringify({
found: true,
status: credit.status,
...(credit.status === "active"
? {
token: credit.token,
username: credit.username,
expiresAt: credit.expiresAt,
tier: credit.tier,
answersLeft,
}
: {}),
}),
{ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } },
);
}
if (url.pathname === "/api/respuesta/enviar") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "POST" },
});
}
if (isRateLimited(`respuesta:${ip}`, 20, WINDOW_MS)) {
return new Response("Too Many Requests", { status: 429 });
}
let text = "";
let token = "";
try {
const body = await req.json();
text = String((body as Record<string, unknown>).text ?? "").trim();
token = String((body as Record<string, unknown>).token ?? "").trim();
} catch {
return new Response("Invalid JSON body", { status: 400 });
}
const adminMode = isAdminAuthorized(req, url);
if (!token && !adminMode) {
return new Response("Token requerido", { status: 401 });
}
if (text.length < 3 || text.length > 150) {
return new Response("La respuesta debe tener entre 3 y 150 caracteres", { status: 400 });
}
const round = gameState.active;
if (!round || !round.prompt) {
return new Response("No hay ronda activa", { status: 409 });
}
let username: string;
let answersLeft: number;
if (adminMode) {
username = "Admin";
insertAdminAnswer(round.num, text, username);
answersLeft = 999;
} else {
const result = submitUserAnswer(token, round.num, text);
if (!result) {
return new Response("Crédito no válido o sin respuestas disponibles", { status: 401 });
}
username = result.username;
answersLeft = result.answersLeft;
}
// Add to live round state and broadcast
round.userAnswers = [...(round.userAnswers ?? []), { username, text }];
broadcast();
log("INFO", "respuesta", "Answer submitted", { username, round: round.num, admin: adminMode, ip });
return new Response(JSON.stringify({ ok: true, answersLeft }), {
status: 200,
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
if (url.pathname === "/api/jugadores") {
return new Response(JSON.stringify(getPlayerScores()), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=30, stale-while-revalidate=60",
},
});
}
if (url.pathname === "/api/admin/login") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
@@ -418,7 +906,7 @@ const server = Bun.serve<WsData>({
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
"Content-Disposition": `attachment; filename="quipslop-export-${Date.now()}.json"`,
"Content-Disposition": `attachment; filename="argumentes-export-${Date.now()}.json"`,
},
});
}
@@ -455,8 +943,10 @@ const server = Bun.serve<WsData>({
gameState.completed = [];
gameState.active = null;
gameState.scores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
gameState.viewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
gameState.done = false;
gameState.isPaused = true;
gameState.autoPaused = false;
gameState.generation += 1;
broadcast();
@@ -491,8 +981,11 @@ const server = Bun.serve<WsData>({
if (url.pathname.endsWith("/pause")) {
gameState.isPaused = true;
gameState.autoPaused = false;
cancelAutoPause();
} else {
gameState.isPaused = false;
gameState.autoPaused = false;
}
broadcast();
const action = url.pathname.endsWith("/pause") ? "Paused" : "Resumed";
@@ -561,6 +1054,14 @@ const server = Bun.serve<WsData>({
headers: { Allow: "GET" },
});
}
const now = Date.now();
if (now >= wsNewConnectionsResetAt) {
wsNewConnections = 0;
wsNewConnectionsResetAt = now + 1000;
}
if (wsNewConnections >= MAX_WS_NEW_PER_SEC) {
return new Response("Too Many Requests", { status: 429 });
}
if (clients.size >= MAX_WS_GLOBAL) {
log("WARN", "ws", "Global WS limit reached, rejecting", {
ip,
@@ -584,6 +1085,7 @@ const server = Bun.serve<WsData>({
log("WARN", "ws", "WebSocket upgrade failed", { ip });
return new Response("WebSocket upgrade failed", { status: 400 });
}
wsNewConnections++;
return undefined;
}
@@ -601,44 +1103,32 @@ const server = Bun.serve<WsData>({
totalClients: clients.size,
uniqueIps: wsByIp.size,
});
// Send current state to the new client only
ws.send(
JSON.stringify({
type: "state",
data: getClientState(),
totalRounds: runs,
viewerCount: clients.size,
version: VERSION,
}),
);
// Notify everyone else with just the viewer count
cancelAutoPause();
if (gameState.autoPaused) {
gameState.isPaused = false;
gameState.autoPaused = false;
log("INFO", "server", "Auto-resumed game — viewer connected");
// Broadcast updated state to all clients (including this new one)
broadcast();
} else {
// Send current state to the new client only
ws.send(
JSON.stringify({
type: "state",
data: getClientState(),
totalRounds: runs,
viewerCount: clients.size,
version: VERSION,
}),
);
}
// Notify everyone of updated viewer count
broadcastViewerCount();
},
message(ws, message) {
try {
const msg = JSON.parse(String(message));
if (msg.type !== "vote") return;
const round = gameState.active;
if (!round || round.phase !== "voting") return;
if (!round.viewerVotingEndsAt || Date.now() > round.viewerVotingEndsAt) return;
if (msg.votedFor !== "A" && msg.votedFor !== "B") return;
const ip = ws.data.ip;
const previousVote = viewerVoters.get(ip);
if (previousVote === msg.votedFor) return; // same vote, ignore
// Undo previous vote if changing
if (previousVote === "A") round.viewerVotesA = Math.max(0, (round.viewerVotesA ?? 0) - 1);
else if (previousVote === "B") round.viewerVotesB = Math.max(0, (round.viewerVotesB ?? 0) - 1);
viewerVoters.set(ip, msg.votedFor);
if (msg.votedFor === "A") round.viewerVotesA = (round.viewerVotesA ?? 0) + 1;
else round.viewerVotesB = (round.viewerVotesB ?? 0) + 1;
ws.send(JSON.stringify({ type: "votedAck", votedFor: msg.votedFor }));
scheduleViewerVoteBroadcast();
} catch {}
message() {
// Viewer voting handled via /api/vote endpoint.
},
close(ws) {
clients.delete(ws);
@@ -648,6 +1138,9 @@ const server = Bun.serve<WsData>({
totalClients: clients.size,
uniqueIps: wsByIp.size,
});
if (clients.size === 0 && !gameState.isPaused) {
scheduleAutoPause();
}
broadcastViewerCount();
},
},
@@ -667,9 +1160,9 @@ const server = Bun.serve<WsData>({
},
});
console.log(`\n🎮 quipslop Web — http://localhost:${server.port}`);
console.log(`\n🎮 argument.es Web — http://localhost:${server.port}`);
console.log(`📡 WebSocket — ws://localhost:${server.port}/ws`);
console.log(`🎯 ${runs} rounds with ${MODELS.length} models\n`);
console.log(`🎯 ${runs} rondas con ${MODELS.length} modelos\n`);
log("INFO", "server", `Web server started on port ${server.port}`, {
runs,
@@ -680,6 +1173,7 @@ log("INFO", "server", `Web server started on port ${server.port}`, {
runGame(runs, gameState, broadcast, () => {
viewerVoters.clear();
userAnswerVoters.clear();
}).then(() => {
console.log(`\n✅ Game complete! Log: ${LOG_FILE}`);
console.log(`\n✅ ¡Juego completado! Registro: ${LOG_FILE}`);
});