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>
517 lines
16 KiB
TypeScript
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();
|
|
}
|