Compare commits
20 Commits
d80c40ed3f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 40c919fc64 | |||
| fe5bb5a5c2 | |||
| f9a8e2544f | |||
| 3e5c080466 | |||
| d42e93b013 | |||
| e772fb5cc0 | |||
| 2fac92356d | |||
| 4b0b9f8f50 | |||
| 2abea42c18 | |||
|
|
ccaa86b4a6 | ||
|
|
79f9dab7fb | ||
|
|
f33277a095 | ||
|
|
af2f055939 | ||
|
|
41deee807a | ||
|
|
8f52bee72b | ||
|
|
8489927b67 | ||
|
|
0295041cda | ||
|
|
eda80110c6 | ||
|
|
0dcb6f71ab | ||
|
|
ba543c1f25 |
46
.env.sample
Normal file
46
.env.sample
Normal 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
|
||||
75
README.md
75
README.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
66
admin.tsx
66
admin.tsx
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
99
broadcast.ts
99
broadcast.ts
@@ -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);
|
||||
|
||||
@@ -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
210
db.ts
@@ -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
30
docker-compose.yml
Normal 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:
|
||||
450
frontend.css
450
frontend.css
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
750
frontend.tsx
750
frontend.tsx
@@ -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">“{a.text}”</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: “{submittedText}”
|
||||
</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: “{submittedText}”</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, tú 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
60
game.ts
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
28
history.tsx
28
history.tsx
@@ -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"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
357
pregunta.css
Normal 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
20
pregunta.html
Normal 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
457
pregunta.tsx
Normal 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, tú 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
1726
prompts.ts
File diff suppressed because it is too large
Load Diff
@@ -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
110
redsys.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
604
server.ts
@@ -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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user