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:
55
server.ts
55
server.ts
@@ -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" },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user