Compare commits
8 Commits
2abea42c18
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 40c919fc64 | |||
| fe5bb5a5c2 | |||
| f9a8e2544f | |||
| 3e5c080466 | |||
| d42e93b013 | |||
| e772fb5cc0 | |||
| 2fac92356d | |||
| 4b0b9f8f50 |
18
.env.sample
18
.env.sample
@@ -26,3 +26,21 @@ MAX_WS_NEW_PER_SEC=50
|
||||
|
||||
# Viewer vote broadcast debounce in ms (default: 250)
|
||||
VIEWER_VOTE_BROADCAST_DEBOUNCE_MS=250
|
||||
|
||||
# Auto-pause: seconds of inactivity before pausing when no viewers are connected (default: 60000 ms)
|
||||
AUTOPAUSE_DELAY_MS=60000
|
||||
|
||||
# ── Redsys (optional — enables paid user question submissions at /pregunta) ────
|
||||
|
||||
# Public base URL of this server (used for Redsys redirect/notification URLs)
|
||||
# Example: https://argument.es
|
||||
PUBLIC_URL=https://argument.es
|
||||
|
||||
# Redsys merchant credentials (get these from your bank / Redsys portal)
|
||||
REDSYS_MERCHANT_CODE=
|
||||
REDSYS_TERMINAL=1
|
||||
# Base64-encoded Redsys secret key (SHA-256 key from the merchant portal)
|
||||
REDSYS_SECRET_KEY=
|
||||
|
||||
# Set to "false" to use the live Redsys gateway (default: test environment)
|
||||
REDSYS_TEST=true
|
||||
|
||||
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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -35,6 +35,7 @@ type GameState = {
|
||||
viewerScores: Record<string, number>;
|
||||
done: boolean;
|
||||
isPaused: boolean;
|
||||
autoPaused?: boolean;
|
||||
generation: number;
|
||||
};
|
||||
type StateMessage = {
|
||||
|
||||
@@ -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
208
db.ts
@@ -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 };
|
||||
}
|
||||
|
||||
307
frontend.css
307
frontend.css
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
519
frontend.tsx
519
frontend.tsx
@@ -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">“{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>
|
||||
);
|
||||
}
|
||||
@@ -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: “{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() {
|
||||
@@ -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, 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} viewerScores={state.viewerScores ?? {}} activeRound={state.active} />
|
||||
<Standings
|
||||
scores={state.scores}
|
||||
viewerScores={state.viewerScores ?? {}}
|
||||
playerScores={playerScores}
|
||||
activeRound={state.active}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
31
game.ts
31
game.ts
@@ -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];
|
||||
|
||||
@@ -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
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 />);
|
||||
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);
|
||||
});
|
||||
430
server.ts
430
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,
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user