From f9a8e2544f3e98a6d3cf3817ec8847e62ebfe32c Mon Sep 17 00:00:00 2001 From: Malin Date: Thu, 5 Mar 2026 12:09:53 +0100 Subject: [PATCH] feat: users answer alongside AI instead of proposing questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- db.ts | 46 +++++++++++------- frontend.css | 48 ++++++++++++++++++- frontend.tsx | 130 +++++++++++++++++++++++++++++++++++---------------- game.ts | 1 + server.ts | 55 +++++++++++++--------- 5 files changed, 198 insertions(+), 82 deletions(-) diff --git a/db.ts b/db.ts index 8dcad0b..ca17de9 100644 --- a/db.ts +++ b/db.ts @@ -95,17 +95,30 @@ export function markQuestionUsed(id: number): void { db.prepare("UPDATE questions SET status = 'used' WHERE id = $id").run({ $id: id }); } -/** Top 7 players by number of questions used, excluding anonymous. */ +/** Top 7 players by number of answers submitted, excluding anonymous. */ export function getPlayerScores(): Record { const rows = db .query( - "SELECT username, COUNT(*) as score FROM questions WHERE status = 'used' AND username != '' GROUP BY username ORDER BY score DESC LIMIT 7" + "SELECT username, COUNT(*) 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])); } -// ── Credits (question-count-based access) ─────────────────────────────────── +// ── 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, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + +// ── Credits (answer-count-based access) ────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS credits ( @@ -177,38 +190,35 @@ export function getCreditByOrder(orderId: string): { } /** - * Atomically validates a credit token, creates a paid question, and increments - * the usage counter. Returns null if the token is invalid, expired, or exhausted. + * 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 consumeCreditQuestion( +export function submitUserAnswer( token: string, + roundNum: number, text: string, -): { username: string; questionsLeft: number | null } | null { +): { username: string; answersLeft: number } | null { const row = db .query( - "SELECT username, expires_at, max_questions, questions_used FROM credits WHERE token = $token AND status = 'active'" + "SELECT username, max_questions, questions_used FROM credits WHERE token = $token AND status = 'active'" ) .get({ $token: token }) as { username: string; - expires_at: number; - max_questions: number | null; + max_questions: number; questions_used: number; } | null; if (!row) return null; - if (row.expires_at < Date.now()) return null; - if (row.max_questions !== null && row.questions_used >= row.max_questions) return null; + if (row.questions_used >= row.max_questions) return null; - const orderId = crypto.randomUUID(); db.transaction(() => { db.prepare( - "INSERT INTO questions (text, order_id, username, status) VALUES ($text, $orderId, $username, 'paid')" - ).run({ $text: text, $orderId: orderId, $username: row.username }); + "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 }); })(); - const questionsLeft = - row.max_questions === null ? null : row.max_questions - row.questions_used - 1; - return { username: row.username, questionsLeft }; + return { username: row.username, answersLeft: row.max_questions - row.questions_used - 1 }; } diff --git a/frontend.css b/frontend.css index a22d466..0a86b42 100644 --- a/frontend.css +++ b/frontend.css @@ -769,7 +769,53 @@ body { white-space: nowrap; } -/* ── Propose Question widget ─────────────────────────────────── */ +/* ── 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; +} + +.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; diff --git a/frontend.tsx b/frontend.tsx index d354625..a40bad3 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -33,6 +33,7 @@ type RoundState = { viewerVotesA?: number; viewerVotesB?: number; viewerVotingEndsAt?: number; + userAnswers?: { username: string; text: string }[]; }; type GameState = { lastCompleted: RoundState | null; @@ -64,15 +65,15 @@ type CreditInfo = { username: string; expiresAt: number; tier: string; - questionsLeft: number | null; + answersLeft: number; }; const CREDIT_STORAGE_KEY = "argumentes_credito"; const PROPOSE_TIERS = [ - { id: "basico", label: "10 preguntas", price: "0,99€" }, - { id: "pro", label: "200 preguntas", price: "9,99€" }, - { id: "ilimitado", label: "Ilimitadas", price: "19,99€" }, + { 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"]; @@ -81,12 +82,16 @@ function loadCredit(): CreditInfo | null { try { const raw = localStorage.getItem(CREDIT_STORAGE_KEY); if (!raw) return null; - const c = JSON.parse(raw) as CreditInfo; + 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; } - return c; + // Migrate old field name + if (c.answersLeft === undefined && c.questionsLeft !== undefined) { + c.answersLeft = c.questionsLeft; + } + return c as CreditInfo; } catch { return null; } @@ -429,6 +434,21 @@ function Arena({ {isDone && votesA === votesB && totalVotes > 0 && (
Empate
)} + + {round.userAnswers && round.userAnswers.length > 0 && ( +
+
Respuestas del público
+
+ {round.userAnswers.map((a, i) => ( +
+ {a.username} + + “{a.text}” +
+ ))} +
+
+ )} ); } @@ -592,9 +612,9 @@ function Standings({ ); } -// ── Propose Question (inline widget) ───────────────────────────────────────── +// ── Propose Answer (inline widget) ─────────────────────────────────────────── -function ProposeQuestion() { +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"; @@ -610,7 +630,8 @@ function ProposeQuestion() { const [text, setText] = useState(""); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); - const [sent, setSent] = useState(false); + const [submittedFor, setSubmittedFor] = useState(null); + const [submittedText, setSubmittedText] = useState(null); const [koDismissed, setKoDismissed] = useState(false); useEffect(() => { @@ -618,6 +639,15 @@ function ProposeQuestion() { setLoaded(true); }, []); + // 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); @@ -632,13 +662,13 @@ function ProposeQuestion() { const data = await res.json() as { found: boolean; status?: string; token?: string; username?: string; expiresAt?: number; tier?: string; - questionsLeft?: number | null; + 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 ?? "", - questionsLeft: data.questionsLeft ?? null, + answersLeft: data.answersLeft ?? 0, }; localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(newCredit)); setCredit(newCredit); @@ -672,13 +702,13 @@ function ProposeQuestion() { } } - async function handleSubmitQuestion(e: React.FormEvent) { + async function handleSubmitAnswer(e: React.FormEvent) { e.preventDefault(); - if (!credit) return; + if (!credit || !activeRound) return; setSubmitError(null); setSubmitting(true); try { - const res = await fetch("/api/pregunta/enviar", { + const res = await fetch("/api/respuesta/enviar", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: text.trim(), token: credit.token }), @@ -687,17 +717,18 @@ function ProposeQuestion() { if (res.status === 401) { localStorage.removeItem(CREDIT_STORAGE_KEY); setCredit(null); - throw new Error("Acceso expirado o sin preguntas disponibles."); + 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; questionsLeft: number | null }; - const updated: CreditInfo = { ...credit, questionsLeft: data.questionsLeft }; + const data = await res.json() as { ok: boolean; answersLeft: number }; + const updated: CreditInfo = { ...credit, answersLeft: data.answersLeft }; localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated)); setCredit(updated); + setSubmittedFor(activeRound.num); + setSubmittedText(text.trim()); setText(""); - setSent(true); - setTimeout(() => setSent(false), 3000); } catch (err) { setSubmitError(err instanceof Error ? err.message : "Error al enviar"); } finally { @@ -708,7 +739,11 @@ function ProposeQuestion() { if (!loaded) return null; const tierInfo = selectedTier ? PROPOSE_TIERS.find(t => t.id === selectedTier) : null; - const exhausted = credit !== null && credit.questionsLeft !== null && credit.questionsLeft <= 0; + const exhausted = credit !== null && credit.answersLeft <= 0; + const hasPrompt = !!(activeRound?.prompt); + const alreadySubmitted = submittedFor === activeRound?.num; + const canAnswer = credit && !exhausted && hasPrompt && !alreadySubmitted && + (activeRound?.phase === "answering" || activeRound?.phase === "prompting"); // Verifying payment if (verifying || (creditOkOrder && !credit)) { @@ -727,35 +762,40 @@ function ProposeQuestion() { ); } - // Active credit — question form + // Active credit if (credit) { - const badge = credit.questionsLeft === null - ? "Ilimitadas" - : `${credit.questionsLeft} restante${credit.questionsLeft !== 1 ? "s" : ""}`; + const badge = `${credit.answersLeft} respuesta${credit.answersLeft !== 1 ? "s" : ""}`; return (
- Propón una pregunta · {credit.username} + Responde junto a las IAs · {credit.username} {badge}
- {sent &&

✓ ¡Enviada! Se usará en el próximo sorteo.

} - {!exhausted ? ( -
+ + {alreadySubmitted && submittedText && ( +

+ ✓ Tu respuesta: “{submittedText}” +

+ )} + + {canAnswer && ( +