feat: users answer alongside AI instead of proposing questions

- Core mechanic change: users now submit answers to the live prompt,
  competing alongside AI models; answers broadcast to all viewers
- New pricing (no time limit, pure count-based, refillable):
  0,99€ = 10 resp · 9,99€ = 300 resp · 19,99€ = 1000 resp
- DB: new user_answers table; submitUserAnswer() atomically validates
  credit, inserts answer, decrements budget; JUGADORES leaderboard
  now scores by user_answers count
- server: /api/respuesta/enviar endpoint; credit activation sets
  expires_at 10 years out (effectively no expiry); answers injected
  into live round state and broadcast via WebSocket
- frontend: ProposeAnswer widget shows current prompt, textarea active
  during answering phase, tracks per-round submission state;
  Arena shows Respuestas del público section live

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 12:09:53 +01:00
parent 3e5c080466
commit f9a8e2544f
5 changed files with 198 additions and 82 deletions

View File

@@ -7,8 +7,7 @@ import broadcastHtml from "./broadcast.html";
import preguntaHtml from "./pregunta.html";
import {
clearAllRounds, getRounds, getAllRounds,
createPendingQuestion, markQuestionPaid,
createPendingCredit, activateCredit, getCreditByOrder, consumeCreditQuestion,
createPendingCredit, activateCredit, getCreditByOrder, submitUserAnswer,
getPlayerScores,
} from "./db.ts";
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
@@ -116,10 +115,10 @@ const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt(
const AUTOPAUSE_DELAY_MS = parsePositiveInt(process.env.AUTOPAUSE_DELAY_MS, 60_000);
const ADMIN_COOKIE = "argumentes_admin";
const CREDIT_TIERS: Record<string, { days: number; amount: number; label: string; maxQuestions: number | null }> = {
basico: { days: 30, amount: 99, label: "10 preguntas", maxQuestions: 10 },
pro: { days: 30, amount: 999, label: "200 preguntas", maxQuestions: 200 },
ilimitado: { days: 30, amount: 1999, label: "Preguntas ilimitadas", maxQuestions: null },
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;
@@ -561,7 +560,8 @@ const server = Bun.serve<WsData>({
if (credit && credit.status === "pending") {
const tierInfo = CREDIT_TIERS[credit.tier];
if (tierInfo) {
const expiresAt = Date.now() + tierInfo.days * 24 * 60 * 60 * 1000;
// 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", {
@@ -613,14 +613,14 @@ const server = Bun.serve<WsData>({
const tierInfo = CREDIT_TIERS[tier];
if (!tierInfo) {
return new Response("Tier inválido (basico | pro | ilimitado)", { status: 400 });
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.maxQuestions);
createPendingCredit(username, orderId, tier, tierInfo.maxAnswers);
const isTest = process.env.REDSYS_TEST !== "false";
const terminal = process.env.REDSYS_TERMINAL ?? "1";
@@ -638,7 +638,7 @@ const server = Bun.serve<WsData>({
urlOk: `${baseUrl}/?credito_ok=${orderId}`,
urlKo: `${baseUrl}/?ko=1`,
merchantUrl: `${baseUrl}/api/redsys/notificacion`,
productDescription: `Acceso argument.es — ${tierInfo.label}`,
productDescription: `argument.es — ${tierInfo.label}`,
});
log("INFO", "credito", "Credit purchase initiated", {
@@ -664,8 +664,9 @@ const server = Bun.serve<WsData>({
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
const questionsLeft =
credit.maxQuestions === null ? null : credit.maxQuestions - credit.questionsUsed;
const answersLeft = credit.maxQuestions === null
? 0
: credit.maxQuestions - credit.questionsUsed;
return new Response(
JSON.stringify({
found: true,
@@ -676,7 +677,7 @@ const server = Bun.serve<WsData>({
username: credit.username,
expiresAt: credit.expiresAt,
tier: credit.tier,
questionsLeft,
answersLeft,
}
: {}),
}),
@@ -684,14 +685,14 @@ const server = Bun.serve<WsData>({
);
}
if (url.pathname === "/api/pregunta/enviar") {
if (url.pathname === "/api/respuesta/enviar") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "POST" },
});
}
if (isRateLimited(`pregunta:${ip}`, 20, WINDOW_MS)) {
if (isRateLimited(`respuesta:${ip}`, 20, WINDOW_MS)) {
return new Response("Too Many Requests", { status: 429 });
}
@@ -708,16 +709,26 @@ const server = Bun.serve<WsData>({
if (!token) {
return new Response("Token requerido", { status: 401 });
}
if (text.length < 10 || text.length > 200) {
return new Response("La pregunta debe tener entre 10 y 200 caracteres", { status: 400 });
if (text.length < 3 || text.length > 150) {
return new Response("La respuesta debe tener entre 3 y 150 caracteres", { status: 400 });
}
const result = consumeCreditQuestion(token, text);
if (!result) {
return new Response("Crédito no válido, expirado o sin preguntas disponibles", { status: 401 });
const round = gameState.active;
if (!round || !round.prompt) {
return new Response("No hay ronda activa", { status: 409 });
}
log("INFO", "pregunta", "Question submitted via credit", { username: result.username, ip });
return new Response(JSON.stringify({ ok: true, questionsLeft: result.questionsLeft }), {
const result = submitUserAnswer(token, round.num, text);
if (!result) {
return new Response("Crédito no válido o sin respuestas disponibles", { status: 401 });
}
// Add to live round state and broadcast
round.userAnswers = [...(round.userAnswers ?? []), { username: result.username, text }];
broadcast();
log("INFO", "respuesta", "User answer submitted", { username: result.username, round: round.num, ip });
return new Response(JSON.stringify({ ok: true, answersLeft: result.answersLeft }), {
status: 200,
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});