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