Files
argument.es/game.ts
Malin 40c919fc64 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>
2026-03-05 18:26:09 +01:00

517 lines
16 KiB
TypeScript

import { generateText } from "ai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { mkdirSync, appendFileSync } from "node:fs";
import { join } from "node:path";
// ── Models ──────────────────────────────────────────────────────────────────
export const MODELS = [
{ id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" },
{ id: "moonshotai/kimi-k2", name: "Kimi K2" },
// { id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
{ id: "deepseek/deepseek-v3.2", name: "DeepSeek 3.2" },
// { id: "z-ai/glm-5", name: "GLM-5" },
{ id: "openai/gpt-5.2", name: "GPT-5.2" },
{ id: "anthropic/claude-opus-4.6", name: "Opus 4.6" },
{ id: "anthropic/claude-sonnet-4.6", name: "Sonnet 4.6" },
{ id: "x-ai/grok-4.1-fast", name: "Grok 4.1" },
// { id: "minimax/minimax-m2.5", name: "MiniMax 2.5" },
] as const;
export type Model = (typeof MODELS)[number];
export const MODEL_COLORS: Record<string, string> = {
"Gemini 3.1 Pro": "cyan",
"Kimi K2": "green",
"Kimi K2.5": "magenta",
"DeepSeek 3.2": "greenBright",
"GLM-5": "cyanBright",
"GPT-5.2": "yellow",
"Opus 4.6": "blue",
"Sonnet 4.6": "red",
"Grok 4.1": "white",
"MiniMax 2.5": "magentaBright",
};
export const NAME_PAD = 16;
// ── Types ───────────────────────────────────────────────────────────────────
export type TaskInfo = {
model: Model;
startedAt: number;
finishedAt?: number;
result?: string;
error?: string;
};
export type VoteInfo = {
voter: Model;
startedAt: number;
finishedAt?: number;
votedFor?: Model;
error?: boolean;
};
export type RoundState = {
num: number;
phase: "prompting" | "answering" | "voting" | "done";
prompter: Model;
promptTask: TaskInfo;
prompt?: string;
contestants: [Model, Model];
answerTasks: [TaskInfo, TaskInfo];
votes: VoteInfo[];
scoreA?: number;
scoreB?: number;
viewerVotesA?: number;
viewerVotesB?: number;
viewerVotingEndsAt?: number;
userAnswers?: { username: string; text: string }[];
userAnswerVotes?: Record<string, number>;
};
export type GameState = {
completed: RoundState[];
active: RoundState | null;
scores: Record<string, number>;
viewerScores: Record<string, number>;
done: boolean;
isPaused: boolean;
autoPaused: boolean;
generation: number;
};
// ── OpenRouter ──────────────────────────────────────────────────────────────
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
extraBody: {
reasoning: {
effort: "medium",
},
},
});
// ── Logger ──────────────────────────────────────────────────────────────────
const LOGS_DIR = join(import.meta.dir, "logs");
mkdirSync(LOGS_DIR, { recursive: true });
const LOG_FILE = join(
LOGS_DIR,
`game-${new Date().toISOString().replace(/[:.]/g, "-")}.log`,
);
export { LOG_FILE };
export function log(
level: "INFO" | "WARN" | "ERROR",
category: string,
message: string,
data?: Record<string, unknown>,
) {
const ts = new Date().toISOString();
let line = `[${ts}] ${level} [${category}] ${message}`;
if (data) {
line += " " + JSON.stringify(data);
}
appendFileSync(LOG_FILE, line + "\n");
if (level === "ERROR") {
console.error(line);
} else if (level === "WARN") {
console.warn(line);
} else {
console.log(line);
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────
export function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j]!, a[i]!];
}
return a;
}
export async function withRetry<T>(
fn: () => Promise<T>,
validate: (result: T) => boolean,
retries = 3,
label = "unknown",
): Promise<T> {
let lastErr: unknown;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const result = await fn();
if (validate(result)) {
log("INFO", label, `Success on attempt ${attempt}`, {
result: typeof result === "string" ? result : String(result),
});
return result;
}
const msg = `Validation failed (attempt ${attempt}/${retries})`;
log("WARN", label, msg, {
result: typeof result === "string" ? result : String(result),
});
lastErr = new Error(`${msg}: ${JSON.stringify(result).slice(0, 100)}`);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log("WARN", label, `Error on attempt ${attempt}/${retries}: ${errMsg}`, {
error: errMsg,
stack: err instanceof Error ? err.stack : undefined,
});
lastErr = err;
}
if (attempt < retries) {
await new Promise((r) => setTimeout(r, 1000 * attempt));
}
}
log("ERROR", label, `All ${retries} attempts failed`, {
lastError: lastErr instanceof Error ? lastErr.message : String(lastErr),
});
throw lastErr;
}
export function isRealString(s: string, minLength = 5): boolean {
return s.length >= minLength;
}
export function cleanResponse(text: string): string {
const trimmed = text.trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
// ── AI functions ────────────────────────────────────────────────────────────
import { ALL_PROMPTS } from "./prompts";
function buildPromptSystem(): string {
const examples = shuffle([...ALL_PROMPTS]).slice(0, 80);
return `Eres un guionista de comedia para el juego Argument.es (similar a Quiplash). Genera una sola pregunta/frase graciosa de completar espacios en blanco que los jugadores intentarán responder. La pregunta debe ser sorprendente y diseñada para provocar respuestas hilarantes. Devuelve ÚNICAMENTE el texto de la pregunta, nada más. Mantenla corta (menos de 15 palabras).
Usa una VARIEDAD amplia de formatos. ¡NO siempre uses "Lo peor de..." — varía! Aquí hay ejemplos del rango de estilos:
${examples.map((p) => `- ${p}`).join("\n")}
Crea algo ORIGINAL — no copies estos ejemplos. Responde SIEMPRE en español.`;
}
export async function callGeneratePrompt(model: Model): Promise<string> {
log("INFO", `prompt:${model.name}`, "Calling API", { modelId: model.id });
const system = buildPromptSystem();
const { text, usage, reasoning } = await generateText({
model: openrouter.chat(model.id),
system,
prompt:
"Genera una sola pregunta original para Argument.es. Sé creativo y no repitas patrones comunes. Responde en español.",
});
log("INFO", `prompt:${model.name}`, "Raw response", {
rawText: text,
usage,
});
return cleanResponse(text);
}
export async function callGenerateAnswer(
model: Model,
prompt: string,
): Promise<string> {
log("INFO", `answer:${model.name}`, "Calling API", {
modelId: model.id,
prompt,
});
const { text, usage, reasoning } = await generateText({
model: openrouter.chat(model.id),
system: `¡Estás jugando Argument.es! Se te dará una frase de completar espacios en blanco. Da la respuesta MÁS GRACIOSA posible. Sé creativo, atrevido, inesperado y conciso. Responde con ÚNICAMENTE tu respuesta — sin comillas, sin explicación, sin preámbulos. Mantenla corta (menos de 12 palabras). Responde SIEMPRE en español.`,
prompt: `Completa la frase: ${prompt}`,
});
log("INFO", `answer:${model.name}`, "Raw response", {
rawText: text,
usage,
});
return cleanResponse(text);
}
export async function callVote(
voter: Model,
prompt: string,
a: { answer: string },
b: { answer: string },
): Promise<"A" | "B"> {
log("INFO", `vote:${voter.name}`, "Calling API", {
modelId: voter.id,
prompt,
answerA: a.answer,
answerB: b.answer,
});
const { text, usage, reasoning } = await generateText({
model: openrouter.chat(voter.id),
system: `Eres un juez en un juego de comedia. Verás una frase de completar espacios en blanco y dos respuestas. Elige cuál respuesta es MÁS GRACIOSA. DEBES responder exactamente con "A" o "B" — nada más.`,
prompt: `Pregunta: "${prompt}"\n\nRespuesta A: "${a.answer}"\nRespuesta B: "${b.answer}"\n\n¿Cuál es más graciosa? Responde solo con A o B.`,
});
log("INFO", `vote:${voter.name}`, "Raw response", { rawText: text, usage });
const cleaned = text.trim().toUpperCase();
if (!cleaned.startsWith("A") && !cleaned.startsWith("B")) {
throw new Error(`Invalid vote: "${text.trim()}"`);
}
return cleaned.startsWith("A") ? "A" : "B";
}
import { saveRound, getNextPendingQuestion, markQuestionUsed, persistUserAnswerVotes } from "./db.ts";
// ── Game loop ───────────────────────────────────────────────────────────────
export async function runGame(
runs: number,
state: GameState,
rerender: () => void,
onViewerVotingStart?: (round: RoundState) => void,
) {
let startRound = 1;
const lastCompletedRound = state.completed.at(-1);
if (lastCompletedRound) {
startRound = lastCompletedRound.num + 1;
}
let endRound = startRound + runs - 1;
for (let r = startRound; r <= endRound; r++) {
while (state.isPaused) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const roundGeneration = state.generation;
// Reset round counter if generation changed (e.g. admin reset)
const latest = state.completed.at(-1);
const expectedR = latest ? latest.num + 1 : 1;
if (r !== expectedR) {
r = expectedR;
endRound = r + runs - 1;
}
const shuffled = shuffle([...MODELS]);
const prompter = shuffled[0]!;
const contA = shuffled[1]!;
const contB = shuffled[2]!;
const voters = [prompter, ...shuffled.slice(3)];
const now = Date.now();
const round: RoundState = {
num: r,
phase: "prompting",
prompter,
promptTask: { model: prompter, startedAt: now },
contestants: [contA, contB],
answerTasks: [
{ model: contA, startedAt: 0 },
{ model: contB, startedAt: 0 },
],
votes: [],
};
state.active = round;
log("INFO", "round", `=== Round ${r}/${runs} ===`, {
prompter: prompter.name,
contestants: [contA.name, contB.name],
voters: voters.map((v) => v.name),
});
rerender();
// ── Prompt phase ──
try {
// Use a user-submitted question if one is pending, otherwise call AI
const pendingQ = getNextPendingQuestion();
let prompt: string;
if (pendingQ) {
markQuestionUsed(pendingQ.id);
prompt = pendingQ.text;
log("INFO", `R${r}:prompt`, "Using user-submitted question", { id: pendingQ.id });
} else {
prompt = await withRetry(
() => callGeneratePrompt(prompter),
(s) => isRealString(s, 10),
3,
`R${r}:prompt:${prompter.name}`,
);
}
if (state.generation !== roundGeneration) {
continue;
}
round.promptTask.finishedAt = Date.now();
round.promptTask.result = prompt;
round.prompt = prompt;
rerender();
} catch {
if (state.generation !== roundGeneration) {
continue;
}
round.promptTask.finishedAt = Date.now();
round.promptTask.error = "Failed after 3 attempts";
round.phase = "done";
state.completed = [...state.completed, round];
state.active = null;
rerender();
continue;
}
// ── Answer phase ──
round.phase = "answering";
const answerStart = Date.now();
round.answerTasks[0].startedAt = answerStart;
round.answerTasks[1].startedAt = answerStart;
rerender();
await Promise.all(
round.answerTasks.map(async (task) => {
if (state.generation !== roundGeneration) {
return;
}
try {
const answer = await withRetry(
() => callGenerateAnswer(task.model, round.prompt!),
(s) => isRealString(s, 3),
3,
`R${r}:answer:${task.model.name}`,
);
if (state.generation !== roundGeneration) {
return;
}
task.result = answer;
} catch {
if (state.generation !== roundGeneration) {
return;
}
task.error = "Failed to answer";
task.result = "[no answer]";
}
if (state.generation !== roundGeneration) {
return;
}
task.finishedAt = Date.now();
rerender();
}),
);
if (state.generation !== roundGeneration) {
continue;
}
// ── Vote phase ──
round.phase = "voting";
const answerA = round.answerTasks[0].result!;
const answerB = round.answerTasks[1].result!;
const voteStart = Date.now();
round.votes = voters.map((v) => ({ voter: v, startedAt: voteStart }));
// Initialize viewer voting
round.viewerVotesA = 0;
round.viewerVotesB = 0;
round.viewerVotingEndsAt = Date.now() + 30_000;
onViewerVotingStart?.(round);
rerender();
await Promise.all([
// Model votes
Promise.all(
round.votes.map(async (vote) => {
if (state.generation !== roundGeneration) {
return;
}
try {
const showAFirst = Math.random() > 0.5;
const first = showAFirst ? { answer: answerA } : { answer: answerB };
const second = showAFirst ? { answer: answerB } : { answer: answerA };
const result = await withRetry(
() => callVote(vote.voter, round.prompt!, first, second),
(v) => v === "A" || v === "B",
3,
`R${r}:vote:${vote.voter.name}`,
);
if (state.generation !== roundGeneration) {
return;
}
const votedFor = showAFirst
? result === "A"
? contA
: contB
: result === "A"
? contB
: contA;
vote.finishedAt = Date.now();
vote.votedFor = votedFor;
} catch {
if (state.generation !== roundGeneration) {
return;
}
vote.finishedAt = Date.now();
vote.error = true;
}
if (state.generation !== roundGeneration) {
return;
}
rerender();
}),
),
// 30-second viewer voting window
new Promise((r) => setTimeout(r, 30_000)),
]);
if (state.generation !== roundGeneration) {
continue;
}
// ── Score ──
let votesA = 0;
let votesB = 0;
for (const v of round.votes) {
if (v.votedFor === contA) votesA++;
else if (v.votedFor === contB) votesB++;
}
round.scoreA = votesA * 100;
round.scoreB = votesB * 100;
round.phase = "done";
if (votesA > votesB) {
state.scores[contA.name] = (state.scores[contA.name] || 0) + 1;
} else if (votesB > votesA) {
state.scores[contB.name] = (state.scores[contB.name] || 0) + 1;
}
// Viewer vote scoring
const vvA = round.viewerVotesA ?? 0;
const vvB = round.viewerVotesB ?? 0;
if (vvA > vvB) {
state.viewerScores[contA.name] = (state.viewerScores[contA.name] || 0) + 1;
} else if (vvB > vvA) {
state.viewerScores[contB.name] = (state.viewerScores[contB.name] || 0) + 1;
}
rerender();
await new Promise((r) => setTimeout(r, 5000));
if (state.generation !== roundGeneration) {
continue;
}
// Persist votes for user answers
if (round.userAnswerVotes && round.userAnswers && round.userAnswers.length > 0) {
persistUserAnswerVotes(round.num, round.userAnswerVotes);
}
// Archive round
saveRound(round);
state.completed = [...state.completed, round];
state.active = null;
rerender();
}
state.done = true;
rerender();
}