Compare commits

...

8 Commits

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:30:58 +01:00
16 changed files with 2509 additions and 418 deletions

View File

@@ -26,3 +26,21 @@ MAX_WS_NEW_PER_SEC=50
# Viewer vote broadcast debounce in ms (default: 250)
VIEWER_VOTE_BROADCAST_DEBOUNCE_MS=250
# Auto-pause: seconds of inactivity before pausing when no viewers are connected (default: 60000 ms)
AUTOPAUSE_DELAY_MS=60000
# ── Redsys (optional — enables paid user question submissions at /pregunta) ────
# Public base URL of this server (used for Redsys redirect/notification URLs)
# Example: https://argument.es
PUBLIC_URL=https://argument.es
# Redsys merchant credentials (get these from your bank / Redsys portal)
REDSYS_MERCHANT_CODE=
REDSYS_TERMINAL=1
# Base64-encoded Redsys secret key (SHA-256 key from the merchant portal)
REDSYS_SECRET_KEY=
# Set to "false" to use the live Redsys gateway (default: test environment)
REDSYS_TEST=true

View File

@@ -1,3 +1,74 @@
# Quipslop
# argument.es
Streamed live on [twitch.tv/quipslop](https://twitch.tv/quipslop).
Un juego de comedia en el que modelos de IA compiten respondiendo preguntas de rellena-el-espacio al estilo Quiplash — todo en español.
Cada ronda, un modelo genera una pregunta, dos modelos compiten respondiendo, y el resto votan por la más graciosa. El público también puede votar directamente desde la web.
## Modelos participantes
- Gemini 3.1 Pro
- Kimi K2
- DeepSeek 3.2
- GPT-5.2
- Claude Opus 4.6
- Claude Sonnet 4.6
- Grok 4.1
## Inicio rápido
```bash
cp .env.sample .env
# Edita .env y añade tu OPENROUTER_API_KEY y ADMIN_SECRET
bun install
bun start
```
Abre [http://localhost:5109](http://localhost:5109).
## Docker
```bash
cp .env.sample .env
# Edita .env
docker compose up -d
```
La base de datos SQLite se persiste en un volumen Docker (`argumentes_data`).
## Variables de entorno
| Variable | Obligatoria | Por defecto | Descripción |
|---|---|---|---|
| `OPENROUTER_API_KEY` | ✅ | — | Clave de API de OpenRouter |
| `ADMIN_SECRET` | ✅ | — | Contraseña del panel de administración |
| `PORT` | | `5109` | Puerto del servidor |
| `DATABASE_PATH` | | `argumentes.sqlite` | Ruta al archivo SQLite |
| `AUTOPAUSE_DELAY_MS` | | `60000` | ms sin espectadores antes de autopausar |
| `PUBLIC_URL` | | — | URL pública (necesaria para Redsys) |
| `REDSYS_MERCHANT_CODE` | | — | Código de comercio Redsys |
| `REDSYS_TERMINAL` | | `1` | Terminal Redsys |
| `REDSYS_SECRET_KEY` | | — | Clave secreta Redsys (Base64) |
| `REDSYS_TEST` | | `true` | `false` para usar el entorno de producción |
Ver `.env.sample` para todas las opciones.
## Panel de administración
Disponible en `/admin`. Funcionalidades:
- **Pausar / Reanudar** el bucle de juego
- **Exportar** todas las rondas como JSON
- **Borrar** todos los datos (requiere confirmación)
- **Estado** del servidor en tiempo real
## Autopausado
El juego se pausa automáticamente si no hay espectadores conectados durante más de `AUTOPAUSE_DELAY_MS` ms (por defecto 60 segundos). En cuanto se conecta un espectador, el juego se reanuda solo. El panel de administración muestra "Esperando espectadores…" en este estado.
## Preguntas del público (`/pregunta`)
Los espectadores pueden pagar 1€ a través de Redsys para proponer una pregunta de completar-la-frase. La pregunta se usa en el siguiente sorteo en lugar de generarla con IA. Requiere configurar las variables `REDSYS_*` en `.env`.
## Cómo funcionan las preguntas
El array `ALL_PROMPTS` en `prompts.ts` sirve únicamente como **guía de estilo**. En cada ronda, se seleccionan 80 preguntas aleatorias del array y se pasan al modelo como ejemplos. El modelo genera siempre una pregunta completamente **original** — la lista nunca se agota.

View File

@@ -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");

View File

@@ -35,6 +35,7 @@ type GameState = {
viewerScores: Record<string, number>;
done: boolean;
isPaused: boolean;
autoPaused?: boolean;
generation: number;
};
type StateMessage = {

View File

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

208
db.ts
View File

@@ -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 };
}

View File

@@ -736,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) {
@@ -791,4 +1097,5 @@ body {
padding: 24px;
gap: 24px;
}
}

View File

@@ -33,6 +33,8 @@ type RoundState = {
viewerVotesA?: number;
viewerVotesB?: number;
viewerVotingEndsAt?: number;
userAnswers?: { username: string; text: string }[];
userAnswerVotes?: Record<string, number>;
};
type GameState = {
lastCompleted: RoundState | null;
@@ -41,6 +43,7 @@ type GameState = {
viewerScores: Record<string, number>;
done: boolean;
isPaused: boolean;
autoPaused?: boolean;
generation: number;
};
type StateMessage = {
@@ -56,6 +59,69 @@ type ViewerCountMessage = {
};
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 ─────────────────────────────────────────────────────
const MODEL_COLORS: Record<string, string> = {
@@ -272,12 +338,16 @@ function Arena({
viewerVotingSecondsLeft,
myVote,
onVote,
myUserAnswerVote,
onUserAnswerVote,
}: {
round: RoundState;
total: number | null;
viewerVotingSecondsLeft: number;
myVote: "A" | "B" | null;
onVote: (side: "A" | "B") => void;
myUserAnswerVote: string | null;
onUserAnswerVote: (username: string) => void;
}) {
const [contA, contB] = round.contestants;
const showVotes = round.phase === "voting" || round.phase === "done";
@@ -369,6 +439,43 @@ function Arena({
{isDone && votesA === votesB && totalVotes > 0 && (
<div className="tie-label">Empate</div>
)}
{round.userAnswers && round.userAnswers.length > 0 && (
<div className="user-answers">
<div className="user-answers__label">Respuestas del público</div>
<div className="user-answers__list">
{round.userAnswers.map((a, i) => {
const voteCount = round.userAnswerVotes?.[a.username] ?? 0;
const isMyVote = myUserAnswerVote === a.username;
return (
<div key={i} className="user-answer">
<div className="user-answer__main">
<span className="user-answer__name">{a.username}</span>
<span className="user-answer__sep"> </span>
<span className="user-answer__text">&ldquo;{a.text}&rdquo;</span>
</div>
{(showCountdown || voteCount > 0) && (
<div className="user-answer__vote">
{showCountdown && (
<button
className={`user-vote-btn ${isMyVote ? "user-vote-btn--active" : ""}`}
onClick={() => onUserAnswerVote(a.username)}
title="Votar"
>
</button>
)}
{voteCount > 0 && (
<span className="user-vote-count">{voteCount}</span>
)}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -449,13 +556,50 @@ function LeaderboardSection({
);
}
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
@@ -473,9 +617,6 @@ function Standings({
<a href="/history" className="standings__link">
Historial
</a>
<a href="https://twitch.tv/argument_es" target="_blank" rel="noopener noreferrer" className="standings__link">
Twitch
</a>
<a href="https://argument.es" target="_blank" rel="noopener noreferrer" className="standings__link">
Web
</a>
@@ -491,10 +632,326 @@ function Standings({
scores={viewerScores}
competing={competing}
/>
{Object.keys(playerScores).length > 0 && (
<PlayerLeaderboard scores={playerScores} />
)}
</aside>
);
}
// ── Propose Answer (inline widget) ───────────────────────────────────────────
function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
const params = new URLSearchParams(window.location.search);
const creditOkOrder = params.get("credito_ok");
const isKo = params.get("ko") === "1";
const [credit, setCredit] = useState<CreditInfo | null>(null);
const [loaded, setLoaded] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [verifying, setVerifying] = useState(false);
const [verifyError, setVerifyError] = useState(false);
const [selectedTier, setSelectedTier] = useState<ProposeTierId | null>(null);
const [username, setUsername] = useState("");
const [buying, setBuying] = useState(false);
const [buyError, setBuyError] = useState<string | null>(null);
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submittedFor, setSubmittedFor] = useState<number | null>(null);
const [submittedText, setSubmittedText] = useState<string | null>(null);
const [koDismissed, setKoDismissed] = useState(false);
useEffect(() => {
setCredit(loadCredit());
setLoaded(true);
// Check if admin is logged in (cookie-based, no token needed)
fetch("/api/admin/status")
.then(r => { if (r.ok) setIsAdmin(true); })
.catch(() => {});
}, []);
// Clear submission state when a new round starts
useEffect(() => {
if (activeRound?.num && submittedFor !== null && activeRound.num !== submittedFor) {
setSubmittedFor(null);
setSubmittedText(null);
setText("");
}
}, [activeRound?.num]);
useEffect(() => {
if (!creditOkOrder || !loaded || credit) return;
setVerifying(true);
let attempts = 0;
async function poll() {
if (attempts >= 15) { setVerifying(false); setVerifyError(true); return; }
attempts++;
try {
const res = await fetch(`/api/credito/estado?order=${encodeURIComponent(creditOkOrder!)}`);
if (res.ok) {
const data = await res.json() as {
found: boolean; status?: string; token?: string;
username?: string; expiresAt?: number; tier?: string;
answersLeft?: number;
};
if (data.found && data.status === "active" && data.token && data.expiresAt) {
const newCredit: CreditInfo = {
token: data.token, username: data.username ?? "",
expiresAt: data.expiresAt, tier: data.tier ?? "",
answersLeft: data.answersLeft ?? 0,
};
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(newCredit));
setCredit(newCredit);
setVerifying(false);
history.replaceState(null, "", "/");
return;
}
}
} catch { /* retry */ }
setTimeout(poll, 2000);
}
poll();
}, [creditOkOrder, loaded, credit]);
async function handleBuyCredit(e: React.FormEvent) {
e.preventDefault();
if (!selectedTier) return;
setBuyError(null);
setBuying(true);
try {
const res = await fetch("/api/credito/iniciar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tier: selectedTier, username: username.trim() }),
});
if (!res.ok) throw new Error(await res.text());
await submitRedsysForm(await res.json());
} catch (err) {
setBuyError(err instanceof Error ? err.message : "Error al procesar el pago");
setBuying(false);
}
}
async function handleSubmitAnswer(e: React.FormEvent) {
e.preventDefault();
if (!credit && !isAdmin) return;
if (!activeRound) return;
setSubmitError(null);
setSubmitting(true);
try {
const res = await fetch("/api/respuesta/enviar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: text.trim(), token: credit?.token ?? "" }),
});
if (!res.ok) {
if (res.status === 401) {
if (!isAdmin) {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
}
throw new Error("Crédito agotado o no válido.");
}
if (res.status === 409) throw new Error("La ronda aún no tiene pregunta activa.");
throw new Error(await res.text() || `Error ${res.status}`);
}
const data = await res.json() as { ok: boolean; answersLeft: number };
if (credit) {
const updated: CreditInfo = { ...credit, answersLeft: data.answersLeft };
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated));
setCredit(updated);
}
setSubmittedFor(activeRound.num);
setSubmittedText(text.trim());
setText("");
} catch (err) {
setSubmitError(err instanceof Error ? err.message : "Error al enviar");
} finally {
setSubmitting(false);
}
}
if (!loaded) return null;
const tierInfo = selectedTier ? PROPOSE_TIERS.find(t => t.id === selectedTier) : null;
const exhausted = credit !== null && credit.answersLeft <= 0;
const hasPrompt = !!(activeRound?.prompt);
const alreadySubmitted = submittedFor === activeRound?.num;
const phaseOk = activeRound?.phase === "answering" || activeRound?.phase === "prompting";
const canAnswer = (isAdmin || (credit && !exhausted)) && hasPrompt && !alreadySubmitted && phaseOk;
// Verifying payment
if (verifying || (creditOkOrder && !credit)) {
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Verificando pago</span>
{!verifyError && <div className="propose__spinner" />}
</div>
{verifyError && (
<p className="propose__msg propose__msg--error">
No se pudo confirmar. Recarga si el pago se completó.
</p>
)}
</div>
);
}
// Active credit
if (credit) {
const badge = `${credit.answersLeft} respuesta${credit.answersLeft !== 1 ? "s" : ""}`;
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Responde junto a las IAs · {credit.username}</span>
<span className={`propose__badge ${exhausted ? "propose__badge--empty" : ""}`}>{badge}</span>
</div>
{alreadySubmitted && submittedText && (
<p className="propose__msg propose__msg--ok">
Tu respuesta: &ldquo;{submittedText}&rdquo;
</p>
)}
{canAnswer && (
<form onSubmit={handleSubmitAnswer}>
<div className="propose__row">
<textarea
className="propose__textarea"
value={text}
onChange={e => setText(e.target.value)}
placeholder="Tu respuesta más graciosa…"
rows={2}
maxLength={150}
autoFocus
/>
<button className="propose__btn" type="submit" disabled={submitting || text.trim().length < 3}>
{submitting ? "…" : "Enviar"}
</button>
</div>
<div className="propose__hint">
{text.length}/150 ·{" "}
<button type="button" className="propose__link-btn" onClick={() => {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
}}>
cerrar sesión
</button>
</div>
{submitError && <p className="propose__msg propose__msg--error">{submitError}</p>}
</form>
)}
{!canAnswer && !alreadySubmitted && !exhausted && (
<p className="propose__msg" style={{ color: "var(--text-muted)" }}>
{!hasPrompt ? "Esperando la siguiente pregunta…" : "Ya no se aceptan respuestas para esta ronda."}
</p>
)}
{exhausted && (
<div className="propose__row">
<p className="propose__msg propose__msg--error" style={{ flex: 1 }}>
Sin respuestas. Recarga para seguir jugando.
</p>
<button className="propose__btn" onClick={() => {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
}}>Recargar</button>
</div>
)}
</div>
);
}
// Admin: show answer form without requiring credit
if (isAdmin) {
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Responde junto a las IAs</span>
<span className="propose__badge" style={{ color: "var(--accent)", borderColor: "var(--accent)", background: "rgba(217,119,87,0.1)" }}>Admin</span>
</div>
{alreadySubmitted && submittedText && (
<p className="propose__msg propose__msg--ok"> Tu respuesta: &ldquo;{submittedText}&rdquo;</p>
)}
{canAnswer && (
<form onSubmit={handleSubmitAnswer}>
<div className="propose__row">
<textarea
className="propose__textarea"
value={text}
onChange={e => setText(e.target.value)}
placeholder="Tu respuesta más graciosa…"
rows={2}
maxLength={150}
autoFocus
/>
<button className="propose__btn" type="submit" disabled={submitting || text.trim().length < 3}>
{submitting ? "…" : "Enviar"}
</button>
</div>
<div className="propose__hint">{text.length}/150</div>
{submitError && <p className="propose__msg propose__msg--error">{submitError}</p>}
</form>
)}
{!canAnswer && !alreadySubmitted && (
<p className="propose__msg" style={{ color: "var(--text-muted)" }}>
{!hasPrompt ? "Esperando la siguiente pregunta…" : "Ya no se aceptan respuestas para esta ronda."}
</p>
)}
</div>
);
}
// Tier selection (purchase)
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Responde junto a las IAs</span>
</div>
{isKo && !koDismissed && (
<p className="propose__msg propose__msg--error">
El pago no se completó.{" "}
<button type="button" className="propose__link-btn" onClick={() => setKoDismissed(true)}>×</button>
</p>
)}
<div className="propose__tiers">
{PROPOSE_TIERS.map(tier => (
<button
key={tier.id}
type="button"
className={`propose__tier ${selectedTier === tier.id ? "propose__tier--selected" : ""}`}
onClick={() => setSelectedTier(tier.id)}
>
<span className="propose__tier__price">{tier.price}</span>
<span className="propose__tier__label">{tier.label}</span>
</button>
))}
</div>
{selectedTier && (
<form onSubmit={handleBuyCredit} className="propose__row propose__row--mt">
<input
type="text"
className="propose__input"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="Tu nombre en el marcador"
maxLength={30}
required
autoFocus
/>
<button type="submit" className="propose__btn" disabled={buying || !username.trim()}>
{buying ? "…" : `Pagar ${tierInfo?.price}`}
</button>
</form>
)}
{buyError && <p className="propose__msg propose__msg--error">{buyError}</p>}
</div>
);
}
// ── Connecting ───────────────────────────────────────────────────────────────
function ConnectingScreen() {
@@ -520,7 +977,23 @@ function App() {
const [connected, setConnected] = useState(false);
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
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>>({});
useEffect(() => {
async function fetchPlayerScores() {
try {
const res = await fetch("/api/jugadores");
if (res.ok) setPlayerScores(await res.json());
} catch {
// ignore
}
}
fetchPlayerScores();
const interval = setInterval(fetchPlayerScores, 60_000);
return () => clearInterval(interval);
}, []);
// Countdown timer for viewer voting
useEffect(() => {
@@ -539,11 +1012,12 @@ function App() {
return () => clearInterval(interval);
}, [state?.active?.viewerVotingEndsAt, state?.active?.phase]);
// Reset my vote when a new round starts
// 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]);
@@ -561,6 +1035,19 @@ function App() {
}
}
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`;
@@ -619,7 +1106,7 @@ function App() {
className="viewer-pill"
style={{ color: "var(--text-muted)", borderColor: "var(--border)" }}
>
En pausa
{state.autoPaused ? "Esperando espectadores…" : "En pausa"}
</div>
)}
<div className="viewer-pill" aria-live="polite">
@@ -638,6 +1125,8 @@ function App() {
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
myVote={myVote}
onVote={handleVote}
myUserAnswerVote={myUserAnswerVote}
onUserAnswerVote={handleUserAnswerVote}
/>
) : (
<div className="waiting">
@@ -653,9 +1142,27 @@ function App() {
<Dots />
</div>
)}
<ProposeAnswer activeRound={state.active} />
<footer className="site-footer">
<p>IAs compiten respondiendo preguntas absurdas los jueces votan, también puedes.</p>
<p>
por{" "}
<a href="https://cloudhost.es" target="_blank" rel="noopener noreferrer">
Cloud Host
</a>
{" "} La web simplificada, la nube gestionada
</p>
</footer>
</main>
<Standings scores={state.scores} viewerScores={state.viewerScores ?? {}} activeRound={state.active} />
<Standings
scores={state.scores}
viewerScores={state.viewerScores ?? {}}
playerScores={playerScores}
activeRound={state.active}
/>
</div>
</div>
);

31
game.ts
View File

@@ -67,6 +67,8 @@ export type RoundState = {
viewerVotesA?: number;
viewerVotesB?: number;
viewerVotingEndsAt?: number;
userAnswers?: { username: string; text: string }[];
userAnswerVotes?: Record<string, number>;
};
export type GameState = {
@@ -76,6 +78,7 @@ export type GameState = {
viewerScores: Record<string, number>;
done: boolean;
isPaused: boolean;
autoPaused: boolean;
generation: number;
};
@@ -264,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 ───────────────────────────────────────────────────────────────
@@ -325,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;
}
@@ -487,6 +499,11 @@ export async function runGame(
continue;
}
// Persist votes for user answers
if (round.userAnswerVotes && round.userAnswers && round.userAnswers.length > 0) {
persistUserAnswerVotes(round.num, round.userAnswerVotes);
}
// Archive round
saveRound(round);
state.completed = [...state.completed, round];

View File

@@ -5,10 +5,7 @@
"private": true,
"scripts": {
"start": "bun server.ts",
"start:cli": "bun quipslop.tsx",
"start:web": "bun --hot server.ts",
"start:stream": "bun ./scripts/stream-browser.ts live",
"start:stream:dryrun": "bun ./scripts/stream-browser.ts dryrun"
"start:dev": "bun --hot server.ts"
},
"devDependencies": {
"@types/bun": "latest",

357
pregunta.css Normal file
View File

@@ -0,0 +1,357 @@
/* ── Reset & Variables ────────────────────────────────────────── */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0a0a;
--surface: #111;
--border: #1c1c1c;
--text: #ededed;
--text-dim: #888;
--text-muted: #444;
--accent: #D97757;
--sans: 'Inter', -apple-system, sans-serif;
--mono: 'JetBrains Mono', 'SF Mono', monospace;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
min-height: 100dvh;
}
/* ── Layout ────────────────────────────────────────────────────── */
.pregunta {
min-height: 100vh;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 20px;
}
.pregunta__panel {
width: 100%;
max-width: 480px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* ── Logo ──────────────────────────────────────────────────────── */
.pregunta__logo {
display: inline-flex;
text-decoration: none;
margin-bottom: 4px;
}
.pregunta__logo img {
height: 20px;
width: auto;
}
/* ── Typography ────────────────────────────────────────────────── */
.pregunta__panel h1 {
font-size: 22px;
font-weight: 700;
line-height: 1.3;
}
.pregunta__sub {
color: var(--text-dim);
font-size: 14px;
}
/* ── Form ──────────────────────────────────────────────────────── */
.pregunta__form {
display: flex;
flex-direction: column;
gap: 12px;
}
.pregunta__label {
font-size: 13px;
font-weight: 500;
color: var(--text-dim);
}
.pregunta__textarea {
width: 100%;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
resize: vertical;
min-height: 88px;
transition: border-color 0.15s;
}
.pregunta__textarea:focus {
outline: none;
border-color: #444;
}
.pregunta__textarea::placeholder {
color: var(--text-muted);
}
.pregunta__hint {
font-size: 12px;
color: var(--text-muted);
font-family: var(--mono);
}
/* ── Error ─────────────────────────────────────────────────────── */
.pregunta__error {
background: rgba(220, 60, 60, 0.08);
border: 1px solid rgba(220, 60, 60, 0.25);
border-radius: 6px;
padding: 10px 14px;
color: #ff6b6b;
font-size: 13px;
}
/* ── Submit ────────────────────────────────────────────────────── */
.pregunta__submit {
padding: 12px 20px;
background: var(--accent);
border: none;
border-radius: 8px;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
font-family: var(--sans);
transition: opacity 0.15s;
}
.pregunta__submit:hover:not(:disabled) {
opacity: 0.85;
}
.pregunta__submit:disabled {
opacity: 0.35;
cursor: not-allowed;
}
/* ── Status button (ok/ko states) ──────────────────────────────── */
.pregunta__btn {
display: inline-block;
padding: 12px 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 14px;
text-decoration: none;
text-align: center;
transition: border-color 0.15s;
}
.pregunta__btn:hover {
border-color: #444;
}
/* ── Quick links ───────────────────────────────────────────────── */
.pregunta__links {
display: flex;
gap: 16px;
align-items: center;
justify-content: center;
}
.pregunta__links a {
color: var(--text-muted);
text-decoration: none;
font-size: 13px;
}
.pregunta__links a:hover {
color: var(--text-dim);
}
.pregunta__links-sep {
color: var(--text-muted);
font-size: 13px;
}
.pregunta__link-btn {
background: none;
border: none;
padding: 0;
color: var(--text-muted);
font-size: 13px;
font-family: var(--sans);
cursor: pointer;
}
.pregunta__link-btn:hover {
color: var(--text-dim);
}
/* ── Header row (logo + credit badge) ─────────────────────────── */
.pregunta__header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pregunta__credit-badge {
font-size: 12px;
font-weight: 600;
color: #4caf7d;
background: rgba(76, 175, 125, 0.12);
border: 1px solid rgba(76, 175, 125, 0.25);
border-radius: 20px;
padding: 4px 12px;
white-space: nowrap;
}
.pregunta__credit-badge--empty {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.03);
border-color: var(--border);
}
/* ── Success banner ────────────────────────────────────────────── */
.pregunta__success {
background: rgba(76, 175, 125, 0.1);
border: 1px solid rgba(76, 175, 125, 0.25);
border-radius: 6px;
padding: 10px 14px;
color: #4caf7d;
font-size: 13px;
font-weight: 500;
}
/* ── Username input ────────────────────────────────────────────── */
.pregunta__input {
width: 100%;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
transition: border-color 0.15s;
}
.pregunta__input:focus {
outline: none;
border-color: #444;
}
.pregunta__input::placeholder {
color: var(--text-muted);
}
/* ── Tier cards ────────────────────────────────────────────────── */
.tier-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.tier-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 18px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
font-family: var(--sans);
color: var(--text);
transition: border-color 0.15s, background 0.15s;
}
.tier-card:hover {
border-color: #444;
}
.tier-card--selected {
border-color: var(--accent);
background: rgba(217, 119, 87, 0.08);
}
.tier-card__price {
font-size: 20px;
font-weight: 700;
color: var(--accent);
}
.tier-card__label {
font-size: 12px;
font-weight: 500;
color: var(--text);
}
.tier-card__sub {
font-size: 11px;
color: var(--text-muted);
}
/* ── Spinner ───────────────────────────────────────────────────── */
.pregunta__spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: pregunta-spin 0.8s linear infinite;
margin: 8px auto;
}
@keyframes pregunta-spin {
to { transform: rotate(360deg); }
}
/* ── Site footer ───────────────────────────────────────────────── */
.pregunta__footer {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
line-height: 1.9;
text-align: center;
}
.pregunta__footer a {
color: var(--text-dim);
text-decoration: none;
}
.pregunta__footer a:hover {
color: var(--text);
text-decoration: underline;
}

20
pregunta.html Normal file
View File

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

457
pregunta.tsx Normal file
View File

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

110
redsys.ts Normal file
View File

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

View File

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

430
server.ts
View File

@@ -4,7 +4,14 @@ import indexHtml from "./index.html";
import historyHtml from "./history.html";
import adminHtml from "./admin.html";
import broadcastHtml from "./broadcast.html";
import { clearAllRounds, getRounds, getAllRounds } from "./db.ts";
import preguntaHtml from "./pregunta.html";
import {
clearAllRounds, getRounds, getAllRounds,
createPendingCredit, activateCredit, getCreditByOrder,
submitUserAnswer, insertAdminAnswer,
getPlayerScores, persistUserAnswerVotes,
} from "./db.ts";
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
import {
MODELS,
LOG_FILE,
@@ -67,6 +74,7 @@ const gameState: GameState = {
viewerScores: initialViewerScores,
done: false,
isPaused: false,
autoPaused: false,
generation: 0,
};
@@ -105,7 +113,14 @@ 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[]>();
@@ -293,10 +308,35 @@ function applyViewerVote(voterId: string, side: ViewerVoteSide): boolean {
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() {
@@ -315,6 +355,7 @@ function getClientState() {
viewerScores: gameState.viewerScores,
done: gameState.done,
isPaused: gameState.isPaused,
autoPaused: gameState.autoPaused,
generation: gameState.generation,
};
}
@@ -369,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);
@@ -422,6 +464,351 @@ const server = Bun.serve<WsData>({
});
}
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", {
@@ -559,6 +946,7 @@ const server = Bun.serve<WsData>({
gameState.viewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
gameState.done = false;
gameState.isPaused = true;
gameState.autoPaused = false;
gameState.generation += 1;
broadcast();
@@ -593,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";
@@ -712,17 +1103,28 @@ 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() {
@@ -736,6 +1138,9 @@ const server = Bun.serve<WsData>({
totalClients: clients.size,
uniqueIps: wsByIp.size,
});
if (clients.size === 0 && !gameState.isPaused) {
scheduleAutoPause();
}
broadcastViewerCount();
},
},
@@ -768,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✅ ¡Juego completado! Registro: ${LOG_FILE}`);
});