feat: add viewer voting on user answers with leaderboard scoring

Viewers can now vote for their favourite audience answers during the
30-second voting window. Votes are persisted to the DB at round end
and aggregated as SUM(votes) in the JUGADORES leaderboard.

- db.ts: add persistUserAnswerVotes(); switch getPlayerScores() to SUM(votes)
- game.ts: add userAnswerVotes to RoundState; persist votes before saveRound
- server.ts: add userAnswerVoters map + /api/vote/respuesta endpoint
- frontend.tsx: add userAnswerVotes type; vote state/handler in App; ▲ buttons in Arena
- frontend.css: flex layout for user-answer rows; user-vote-btn styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 18:26:09 +01:00
parent fe5bb5a5c2
commit 40c919fc64
5 changed files with 182 additions and 12 deletions

View File

@@ -9,7 +9,7 @@ import {
clearAllRounds, getRounds, getAllRounds,
createPendingCredit, activateCredit, getCreditByOrder,
submitUserAnswer, insertAdminAnswer,
getPlayerScores,
getPlayerScores, persistUserAnswerVotes,
} from "./db.ts";
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
import {
@@ -336,6 +336,7 @@ function cancelAutoPause() {
const clients = new Set<ServerWebSocket<WsData>>();
const viewerVoters = new Map<string, "A" | "B">();
const userAnswerVoters = new Map<string, string>(); // voterIp → voted-for username
let viewerVoteBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleViewerVoteBroadcast() {
@@ -463,6 +464,57 @@ const server = Bun.serve<WsData>({
});
}
if (url.pathname === "/api/vote/respuesta") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "POST" },
});
}
let username = "";
try {
const body = await req.json();
username = String((body as Record<string, unknown>).username ?? "").trim();
} catch {
return new Response("Invalid JSON body", { status: 400 });
}
const round = gameState.active;
const votingOpen =
round?.phase === "voting" &&
round.viewerVotingEndsAt &&
Date.now() <= round.viewerVotingEndsAt;
if (!votingOpen || !round) {
return new Response(JSON.stringify({ ok: false, reason: "voting closed" }), {
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
const answers = round.userAnswers ?? [];
if (!answers.some((a) => a.username === username)) {
return new Response("Unknown answer", { status: 400 });
}
const prevVote = userAnswerVoters.get(ip);
if (prevVote !== username) {
// Undo previous vote
if (prevVote) {
round.userAnswerVotes = { ...(round.userAnswerVotes ?? {}) };
round.userAnswerVotes[prevVote] = Math.max(0, (round.userAnswerVotes[prevVote] ?? 0) - 1);
}
userAnswerVoters.set(ip, username);
round.userAnswerVotes = { ...(round.userAnswerVotes ?? {}) };
round.userAnswerVotes[username] = (round.userAnswerVotes[username] ?? 0) + 1;
scheduleViewerVoteBroadcast();
}
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
if (url.pathname === "/api/pregunta/iniciar") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
@@ -1121,6 +1173,7 @@ log("INFO", "server", `Web server started on port ${server.port}`, {
runGame(runs, gameState, broadcast, () => {
viewerVoters.clear();
userAnswerVoters.clear();
}).then(() => {
console.log(`\n✅ ¡Juego completado! Registro: ${LOG_FILE}`);
});