feat: footer, auto-pause on no viewers, and Redsys question submissions
- Footer: add site-footer with one-liner + Cloud Host branding linking to cloudhost.es (mobile only, hidden on desktop) - Auto-pause: game pauses automatically after AUTOPAUSE_DELAY_MS (default 60s) with no viewers connected; auto-resumes when first viewer connects; shows "Esperando espectadores…" in the UI - Redsys: new /pregunta page lets viewers pay 1€ via Redsys to submit a fill-in question; submitted questions are used as prompts in the next round instead of AI generation; new redsys.ts implements HMAC_SHA256_V1 signing (3DES key derivation + HMAC-SHA256); notification endpoint at /api/redsys/notificacion handles server-to-server confirmation - db.ts: questions table with createPendingQuestion / markQuestionPaid / getNextPendingQuestion / markQuestionUsed - .env.sample: added AUTOPAUSE_DELAY_MS, PUBLIC_URL, REDSYS_* vars Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
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 in ms (default: 250)
|
||||||
VIEWER_VOTE_BROADCAST_DEBOUNCE_MS=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
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -43,6 +43,12 @@ La base de datos SQLite se persiste en un volumen Docker (`argumentes_data`).
|
|||||||
| `ADMIN_SECRET` | ✅ | — | Contraseña del panel de administración |
|
| `ADMIN_SECRET` | ✅ | — | Contraseña del panel de administración |
|
||||||
| `PORT` | | `5109` | Puerto del servidor |
|
| `PORT` | | `5109` | Puerto del servidor |
|
||||||
| `DATABASE_PATH` | | `argumentes.sqlite` | Ruta al archivo SQLite |
|
| `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.
|
Ver `.env.sample` para todas las opciones.
|
||||||
|
|
||||||
@@ -55,6 +61,14 @@ Disponible en `/admin`. Funcionalidades:
|
|||||||
- **Borrar** todos los datos (requiere confirmación)
|
- **Borrar** todos los datos (requiere confirmación)
|
||||||
- **Estado** del servidor en tiempo real
|
- **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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type GameState = {
|
|||||||
viewerScores: Record<string, number>;
|
viewerScores: Record<string, number>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
|
autoPaused?: boolean;
|
||||||
generation: number;
|
generation: number;
|
||||||
};
|
};
|
||||||
type StateMessage = {
|
type StateMessage = {
|
||||||
|
|||||||
33
db.ts
33
db.ts
@@ -41,3 +41,36 @@ export function clearAllRounds() {
|
|||||||
db.exec("DELETE FROM rounds;");
|
db.exec("DELETE FROM rounds;");
|
||||||
db.exec("DELETE FROM sqlite_sequence WHERE name = 'rounds';");
|
db.exec("DELETE FROM sqlite_sequence WHERE name = 'rounds';");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Questions (user-submitted via Redsys) ───────────────────────────────────
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS questions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
order_id TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
export function createPendingQuestion(text: string, orderId: string): number {
|
||||||
|
const stmt = db.prepare("INSERT INTO questions (text, order_id) VALUES ($text, $orderId)");
|
||||||
|
const result = stmt.run({ $text: text, $orderId: orderId });
|
||||||
|
return result.lastInsertRowid as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markQuestionPaid(orderId: string): boolean {
|
||||||
|
const stmt = db.prepare("UPDATE questions SET status = 'paid' WHERE order_id = $orderId AND status = 'pending'");
|
||||||
|
const result = stmt.run({ $orderId: orderId });
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextPendingQuestion(): { id: number; text: string; order_id: string } | null {
|
||||||
|
return db.query("SELECT id, text, order_id FROM questions WHERE status = 'paid' ORDER BY id ASC LIMIT 1")
|
||||||
|
.get() as { id: number; text: string; order_id: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markQuestionUsed(id: number): void {
|
||||||
|
db.prepare("UPDATE questions SET status = 'used' WHERE id = $id").run({ $id: id });
|
||||||
|
}
|
||||||
|
|||||||
24
frontend.css
24
frontend.css
@@ -736,6 +736,28 @@ body {
|
|||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
|
||||||
|
/* ── Site Footer ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer__link {
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer__link:hover {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Desktop (1024px+) ───────────────────────────────────────── */
|
/* ── Desktop (1024px+) ───────────────────────────────────────── */
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
@@ -791,4 +813,6 @@ body {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-footer { display: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
18
frontend.tsx
18
frontend.tsx
@@ -41,6 +41,7 @@ type GameState = {
|
|||||||
viewerScores: Record<string, number>;
|
viewerScores: Record<string, number>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
|
autoPaused?: boolean;
|
||||||
generation: number;
|
generation: number;
|
||||||
};
|
};
|
||||||
type StateMessage = {
|
type StateMessage = {
|
||||||
@@ -616,7 +617,7 @@ function App() {
|
|||||||
className="viewer-pill"
|
className="viewer-pill"
|
||||||
style={{ color: "var(--text-muted)", borderColor: "var(--border)" }}
|
style={{ color: "var(--text-muted)", borderColor: "var(--border)" }}
|
||||||
>
|
>
|
||||||
En pausa
|
{state.autoPaused ? "Esperando espectadores…" : "En pausa"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="viewer-pill" aria-live="polite">
|
<div className="viewer-pill" aria-live="polite">
|
||||||
@@ -654,6 +655,21 @@ function App() {
|
|||||||
|
|
||||||
<Standings scores={state.scores} viewerScores={state.viewerScores ?? {}} activeRound={state.active} />
|
<Standings scores={state.scores} viewerScores={state.viewerScores ?? {}} activeRound={state.active} />
|
||||||
</div>
|
</div>
|
||||||
|
<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"
|
||||||
|
className="site-footer__link"
|
||||||
|
>
|
||||||
|
Cloud Host
|
||||||
|
</a>
|
||||||
|
{" "}— La web simplificada, la nube gestionada
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
24
game.ts
24
game.ts
@@ -76,6 +76,7 @@ export type GameState = {
|
|||||||
viewerScores: Record<string, number>;
|
viewerScores: Record<string, number>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
|
autoPaused: boolean;
|
||||||
generation: number;
|
generation: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -264,7 +265,7 @@ export async function callVote(
|
|||||||
return cleaned.startsWith("A") ? "A" : "B";
|
return cleaned.startsWith("A") ? "A" : "B";
|
||||||
}
|
}
|
||||||
|
|
||||||
import { saveRound } from "./db.ts";
|
import { saveRound, getNextPendingQuestion, markQuestionUsed } from "./db.ts";
|
||||||
|
|
||||||
// ── Game loop ───────────────────────────────────────────────────────────────
|
// ── Game loop ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -325,12 +326,21 @@ export async function runGame(
|
|||||||
|
|
||||||
// ── Prompt phase ──
|
// ── Prompt phase ──
|
||||||
try {
|
try {
|
||||||
const prompt = await withRetry(
|
// Use a user-submitted question if one is pending, otherwise call AI
|
||||||
() => callGeneratePrompt(prompter),
|
const pendingQ = getNextPendingQuestion();
|
||||||
(s) => isRealString(s, 10),
|
let prompt: string;
|
||||||
3,
|
if (pendingQ) {
|
||||||
`R${r}:prompt:${prompter.name}`,
|
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) {
|
if (state.generation !== roundGeneration) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
187
pregunta.css
Normal file
187
pregunta.css
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/* ── Reset & Variables ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--surface: #111;
|
||||||
|
--border: #1c1c1c;
|
||||||
|
--text: #ededed;
|
||||||
|
--text-dim: #888;
|
||||||
|
--text-muted: #444;
|
||||||
|
--accent: #D97757;
|
||||||
|
--sans: 'Inter', -apple-system, sans-serif;
|
||||||
|
--mono: 'JetBrains Mono', 'SF Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Logo ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__logo {
|
||||||
|
display: inline-flex;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__logo img {
|
||||||
|
height: 20px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Typography ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__panel h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__sub {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 88px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__textarea::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Error ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__error {
|
||||||
|
background: rgba(220, 60, 60, 0.08);
|
||||||
|
border: 1px solid rgba(220, 60, 60, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Submit ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__submit {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--sans);
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__submit:hover:not(:disabled) {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__submit:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status button (ok/ko states) ──────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__btn:hover {
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Quick links ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__links {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__links a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__links a:hover {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
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>
|
||||||
145
pregunta.tsx
Normal file
145
pregunta.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./pregunta.css";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const isOk = params.get("ok") === "1";
|
||||||
|
const isKo = params.get("ko") === "1";
|
||||||
|
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (isOk) {
|
||||||
|
return (
|
||||||
|
<div className="pregunta">
|
||||||
|
<div className="pregunta__panel">
|
||||||
|
<a href="/" className="pregunta__logo">
|
||||||
|
<img src="/assets/logo.svg" alt="argument.es" />
|
||||||
|
</a>
|
||||||
|
<h1>¡Pregunta enviada!</h1>
|
||||||
|
<p className="pregunta__sub">
|
||||||
|
Tu pregunta se usará en el próximo sorteo entre las IAs. ¡Gracias por participar!
|
||||||
|
</p>
|
||||||
|
<a href="/" className="pregunta__btn">Volver al juego</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 pregunta no ha sido guardada.
|
||||||
|
</p>
|
||||||
|
<a href="/pregunta" className="pregunta__btn">Intentar de nuevo</a>
|
||||||
|
<div className="pregunta__links">
|
||||||
|
<a href="/">Volver al juego</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/pregunta/iniciar", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ text: text.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = await res.text();
|
||||||
|
throw new Error(msg || `Error ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
tpvUrl: string;
|
||||||
|
merchantParams: string;
|
||||||
|
signature: string;
|
||||||
|
signatureVersion: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build and auto-submit the Redsys payment form
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.method = "POST";
|
||||||
|
form.action = data.tpvUrl;
|
||||||
|
for (const [name, value] of Object.entries({
|
||||||
|
Ds_SignatureVersion: data.signatureVersion,
|
||||||
|
Ds_MerchantParameters: data.merchantParams,
|
||||||
|
Ds_Signature: data.signature,
|
||||||
|
})) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = name;
|
||||||
|
input.value = value;
|
||||||
|
form.appendChild(input);
|
||||||
|
}
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Error al procesar la solicitud");
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pregunta">
|
||||||
|
<div className="pregunta__panel">
|
||||||
|
<a href="/" className="pregunta__logo">
|
||||||
|
<img src="/assets/logo.svg" alt="argument.es" />
|
||||||
|
</a>
|
||||||
|
<h1>Propón una pregunta</h1>
|
||||||
|
<p className="pregunta__sub">
|
||||||
|
Paga 1€ y tu pregunta de completar-la-frase se usará en el próximo sorteo entre las IAs.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} 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: "Lo que más vergüenza da hacer en ___"'
|
||||||
|
maxLength={200}
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="pregunta__hint">
|
||||||
|
{text.length}/200 caracteres · mínimo 10
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="pregunta__error">{error}</div>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="pregunta__submit"
|
||||||
|
disabled={submitting || text.trim().length < 10}
|
||||||
|
>
|
||||||
|
{submitting ? "Redirigiendo a pago…" : "Pagar 1€ y enviar"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="pregunta__links">
|
||||||
|
<a href="/">Volver al juego</a>
|
||||||
|
</div>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
173
server.ts
173
server.ts
@@ -4,7 +4,9 @@ import indexHtml from "./index.html";
|
|||||||
import historyHtml from "./history.html";
|
import historyHtml from "./history.html";
|
||||||
import adminHtml from "./admin.html";
|
import adminHtml from "./admin.html";
|
||||||
import broadcastHtml from "./broadcast.html";
|
import broadcastHtml from "./broadcast.html";
|
||||||
import { clearAllRounds, getRounds, getAllRounds } from "./db.ts";
|
import preguntaHtml from "./pregunta.html";
|
||||||
|
import { clearAllRounds, getRounds, getAllRounds, createPendingQuestion, markQuestionPaid } from "./db.ts";
|
||||||
|
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
|
||||||
import {
|
import {
|
||||||
MODELS,
|
MODELS,
|
||||||
LOG_FILE,
|
LOG_FILE,
|
||||||
@@ -67,6 +69,7 @@ const gameState: GameState = {
|
|||||||
viewerScores: initialViewerScores,
|
viewerScores: initialViewerScores,
|
||||||
done: false,
|
done: false,
|
||||||
isPaused: false,
|
isPaused: false,
|
||||||
|
autoPaused: false,
|
||||||
generation: 0,
|
generation: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,6 +108,7 @@ const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt(
|
|||||||
process.env.VIEWER_VOTE_BROADCAST_DEBOUNCE_MS,
|
process.env.VIEWER_VOTE_BROADCAST_DEBOUNCE_MS,
|
||||||
250,
|
250,
|
||||||
);
|
);
|
||||||
|
const AUTOPAUSE_DELAY_MS = parsePositiveInt(process.env.AUTOPAUSE_DELAY_MS, 60_000);
|
||||||
const ADMIN_COOKIE = "argumentes_admin";
|
const ADMIN_COOKIE = "argumentes_admin";
|
||||||
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
||||||
|
|
||||||
@@ -293,6 +297,30 @@ function applyViewerVote(voterId: string, side: ViewerVoteSide): boolean {
|
|||||||
return true;
|
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 ───────────────────────────────────────────────────────
|
// ── WebSocket clients ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const clients = new Set<ServerWebSocket<WsData>>();
|
const clients = new Set<ServerWebSocket<WsData>>();
|
||||||
@@ -315,6 +343,7 @@ function getClientState() {
|
|||||||
viewerScores: gameState.viewerScores,
|
viewerScores: gameState.viewerScores,
|
||||||
done: gameState.done,
|
done: gameState.done,
|
||||||
isPaused: gameState.isPaused,
|
isPaused: gameState.isPaused,
|
||||||
|
autoPaused: gameState.autoPaused,
|
||||||
generation: gameState.generation,
|
generation: gameState.generation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -369,6 +398,7 @@ const server = Bun.serve<WsData>({
|
|||||||
"/history": historyHtml,
|
"/history": historyHtml,
|
||||||
"/admin": adminHtml,
|
"/admin": adminHtml,
|
||||||
"/broadcast": broadcastHtml,
|
"/broadcast": broadcastHtml,
|
||||||
|
"/pregunta": preguntaHtml,
|
||||||
},
|
},
|
||||||
async fetch(req, server) {
|
async fetch(req, server) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
@@ -422,6 +452,107 @@ const server = Bun.serve<WsData>({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const marked = markQuestionPaid(orderId);
|
||||||
|
log("INFO", "redsys", "Question marked as paid", { orderId, marked });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log("INFO", "redsys", "Payment not approved", {
|
||||||
|
response: decoded["Ds_Response"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("ok", { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/admin/login") {
|
if (url.pathname === "/api/admin/login") {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return new Response("Method Not Allowed", {
|
return new Response("Method Not Allowed", {
|
||||||
@@ -559,6 +690,7 @@ const server = Bun.serve<WsData>({
|
|||||||
gameState.viewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
|
gameState.viewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
|
||||||
gameState.done = false;
|
gameState.done = false;
|
||||||
gameState.isPaused = true;
|
gameState.isPaused = true;
|
||||||
|
gameState.autoPaused = false;
|
||||||
gameState.generation += 1;
|
gameState.generation += 1;
|
||||||
broadcast();
|
broadcast();
|
||||||
|
|
||||||
@@ -593,8 +725,11 @@ const server = Bun.serve<WsData>({
|
|||||||
|
|
||||||
if (url.pathname.endsWith("/pause")) {
|
if (url.pathname.endsWith("/pause")) {
|
||||||
gameState.isPaused = true;
|
gameState.isPaused = true;
|
||||||
|
gameState.autoPaused = false;
|
||||||
|
cancelAutoPause();
|
||||||
} else {
|
} else {
|
||||||
gameState.isPaused = false;
|
gameState.isPaused = false;
|
||||||
|
gameState.autoPaused = false;
|
||||||
}
|
}
|
||||||
broadcast();
|
broadcast();
|
||||||
const action = url.pathname.endsWith("/pause") ? "Paused" : "Resumed";
|
const action = url.pathname.endsWith("/pause") ? "Paused" : "Resumed";
|
||||||
@@ -712,17 +847,28 @@ const server = Bun.serve<WsData>({
|
|||||||
totalClients: clients.size,
|
totalClients: clients.size,
|
||||||
uniqueIps: wsByIp.size,
|
uniqueIps: wsByIp.size,
|
||||||
});
|
});
|
||||||
// Send current state to the new client only
|
|
||||||
ws.send(
|
cancelAutoPause();
|
||||||
JSON.stringify({
|
|
||||||
type: "state",
|
if (gameState.autoPaused) {
|
||||||
data: getClientState(),
|
gameState.isPaused = false;
|
||||||
totalRounds: runs,
|
gameState.autoPaused = false;
|
||||||
viewerCount: clients.size,
|
log("INFO", "server", "Auto-resumed game — viewer connected");
|
||||||
version: VERSION,
|
// Broadcast updated state to all clients (including this new one)
|
||||||
}),
|
broadcast();
|
||||||
);
|
} else {
|
||||||
// Notify everyone else with just the viewer count
|
// 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();
|
broadcastViewerCount();
|
||||||
},
|
},
|
||||||
message() {
|
message() {
|
||||||
@@ -736,6 +882,9 @@ const server = Bun.serve<WsData>({
|
|||||||
totalClients: clients.size,
|
totalClients: clients.size,
|
||||||
uniqueIps: wsByIp.size,
|
uniqueIps: wsByIp.size,
|
||||||
});
|
});
|
||||||
|
if (clients.size === 0 && !gameState.isPaused) {
|
||||||
|
scheduleAutoPause();
|
||||||
|
}
|
||||||
broadcastViewerCount();
|
broadcastViewerCount();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user