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

46
db.ts
View File

@@ -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<string, number> {
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 };
}