Files
argument.es/game.ts

510 lines
16 KiB
TypeScript
Raw Normal View History

2026-02-19 22:46:41 -08:00
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" },
2026-02-20 00:28:48 -08:00
// { id: "z-ai/glm-5", name: "GLM-5" },
2026-02-19 22:46:41 -08:00
{ 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" },
2026-02-20 03:49:57 -08:00
// { id: "minimax/minimax-m2.5", name: "MiniMax 2.5" },
2026-02-19 22:46:41 -08:00
] 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",
2026-02-20 02:52:39 -08:00
"MiniMax 2.5": "magentaBright",
2026-02-19 22:46:41 -08:00
};
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;
2026-02-22 17:22:59 -08:00
viewerVotesA?: number;
viewerVotesB?: number;
viewerVotingEndsAt?: number;
2026-02-19 22:46:41 -08:00
};
export type GameState = {
completed: RoundState[];
active: RoundState | null;
scores: Record<string, number>;
2026-02-22 18:44:49 -08:00
viewerScores: Record<string, number>;
2026-02-19 22:46:41 -08:00
done: boolean;
2026-02-20 04:26:14 -08:00
isPaused: boolean;
autoPaused: boolean;
2026-02-22 01:20:19 -08:00
generation: number;
2026-02-19 22:46:41 -08:00
};
// ── OpenRouter ──────────────────────────────────────────────────────────────
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
2026-02-22 05:23:46 -08:00
extraBody: {
reasoning: {
effort: "medium",
},
},
2026-02-19 22:46:41 -08:00
});
// ── 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) {
2026-02-22 16:23:46 -08:00
line += " " + JSON.stringify(data);
2026-02-19 22:46:41 -08:00
}
appendFileSync(LOG_FILE, line + "\n");
2026-02-22 16:23:46 -08:00
if (level === "ERROR") {
console.error(line);
} else if (level === "WARN") {
console.warn(line);
} else {
console.log(line);
}
2026-02-19 22:46:41 -08:00
}
// ── 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 {
2026-02-20 05:34:04 -08:00
const trimmed = text.trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
2026-02-19 22:46:41 -08:00
}
// ── AI functions ────────────────────────────────────────────────────────────
2026-02-20 05:26:20 -08:00
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).
2026-02-19 22:46:41 -08:00
Usa una VARIEDAD amplia de formatos. ¡NO siempre uses "Lo peor de..." varía! Aquí hay ejemplos del rango de estilos:
2026-02-19 22:46:41 -08:00
2026-02-20 05:26:20 -08:00
${examples.map((p) => `- ${p}`).join("\n")}
2026-02-19 22:46:41 -08:00
Crea algo ORIGINAL no copies estos ejemplos. Responde SIEMPRE en español.`;
2026-02-20 05:26:20 -08:00
}
2026-02-19 22:46:41 -08:00
export async function callGeneratePrompt(model: Model): Promise<string> {
log("INFO", `prompt:${model.name}`, "Calling API", { modelId: model.id });
2026-02-20 05:26:20 -08:00
const system = buildPromptSystem();
2026-02-22 05:23:46 -08:00
const { text, usage, reasoning } = await generateText({
2026-02-19 22:46:41 -08:00
model: openrouter.chat(model.id),
2026-02-20 05:26:20 -08:00
system,
2026-02-19 22:46:41 -08:00
prompt:
"Genera una sola pregunta original para Argument.es. Sé creativo y no repitas patrones comunes. Responde en español.",
2026-02-19 22:46:41 -08:00
});
2026-02-22 05:23:46 -08:00
2026-02-19 22:46:41 -08:00
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,
});
2026-02-22 05:23:46 -08:00
const { text, usage, reasoning } = await generateText({
2026-02-19 22:46:41 -08:00
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}`,
2026-02-19 22:46:41 -08:00
});
2026-02-22 05:23:46 -08:00
2026-02-19 22:46:41 -08:00
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,
});
2026-02-22 05:23:46 -08:00
const { text, usage, reasoning } = await generateText({
2026-02-19 22:46:41 -08:00
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.`,
2026-02-19 22:46:41 -08:00
});
2026-02-22 05:23:46 -08:00
2026-02-19 22:46:41 -08:00
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 } from "./db.ts";
2026-02-20 00:28:48 -08:00
2026-02-19 22:46:41 -08:00
// ── Game loop ───────────────────────────────────────────────────────────────
export async function runGame(
runs: number,
state: GameState,
rerender: () => void,
2026-02-22 21:50:01 -08:00
onViewerVotingStart?: (round: RoundState) => void,
2026-02-19 22:46:41 -08:00
) {
2026-02-20 04:49:40 -08:00
let startRound = 1;
2026-02-22 01:20:19 -08:00
const lastCompletedRound = state.completed.at(-1);
if (lastCompletedRound) {
startRound = lastCompletedRound.num + 1;
2026-02-20 04:49:40 -08:00
}
2026-02-22 04:32:41 -08:00
let endRound = startRound + runs - 1;
2026-02-20 04:49:40 -08:00
for (let r = startRound; r <= endRound; r++) {
2026-02-20 04:26:14 -08:00
while (state.isPaused) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
2026-02-22 01:20:19 -08:00
const roundGeneration = state.generation;
2026-02-20 04:26:14 -08:00
2026-02-22 04:32:41 -08:00
// 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;
}
2026-02-19 22:46:41 -08:00
const shuffled = shuffle([...MODELS]);
const prompter = shuffled[0]!;
const contA = shuffled[1]!;
const contB = shuffled[2]!;
2026-02-20 04:49:40 -08:00
const voters = [prompter, ...shuffled.slice(3)];
2026-02-19 22:46:41 -08:00
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}`,
);
}
2026-02-22 01:20:19 -08:00
if (state.generation !== roundGeneration) {
continue;
}
2026-02-19 22:46:41 -08:00
round.promptTask.finishedAt = Date.now();
round.promptTask.result = prompt;
round.prompt = prompt;
rerender();
} catch {
2026-02-22 01:20:19 -08:00
if (state.generation !== roundGeneration) {
continue;
}
2026-02-19 22:46:41 -08:00
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) => {
2026-02-22 01:20:19 -08:00
if (state.generation !== roundGeneration) {
return;
}
2026-02-19 22:46:41 -08:00
try {
const answer = await withRetry(
() => callGenerateAnswer(task.model, round.prompt!),
(s) => isRealString(s, 3),
3,
`R${r}:answer:${task.model.name}`,
);
2026-02-22 01:20:19 -08:00
if (state.generation !== roundGeneration) {
return;
}
2026-02-19 22:46:41 -08:00
task.result = answer;
} catch {
2026-02-22 01:20:19 -08:00
if (state.generation !== roundGeneration) {
return;
}
2026-02-19 22:46:41 -08:00
task.error = "Failed to answer";
task.result = "[no answer]";
}
2026-02-22 01:20:19 -08:00
if (state.generation !== roundGeneration) {
return;
}
2026-02-19 22:46:41 -08:00
task.finishedAt = Date.now();
rerender();
}),
);
2026-02-22 01:20:19 -08:00
if (state.generation !== roundGeneration) {
continue;
}
2026-02-19 22:46:41 -08:00
// ── 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 }));
2026-02-22 17:22:59 -08:00
// Initialize viewer voting
round.viewerVotesA = 0;
round.viewerVotesB = 0;
round.viewerVotingEndsAt = Date.now() + 30_000;
2026-02-22 21:50:01 -08:00
onViewerVotingStart?.(round);
2026-02-19 22:46:41 -08:00
rerender();
2026-02-22 17:22:59 -08:00
await Promise.all([
// Model votes
Promise.all(
2026-02-19 22:46:41 -08:00
round.votes.map(async (vote) => {
2026-02-22 01:20:19 -08:00
if (state.generation !== roundGeneration) {
return;
}
2026-02-19 22:46:41 -08:00
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}`,
);
2026-02-22 01:20:19 -08:00
if (state.generation !== roundGeneration) {
return;
}
2026-02-19 22:46:41 -08:00
const votedFor = showAFirst
? result === "A"
? contA
: contB
: result === "A"
? contB
: contA;
vote.finishedAt = Date.now();
vote.votedFor = votedFor;
} catch {
2026-02-22 01:20:19 -08:00
if (state.generation !== roundGeneration) {
return;
}
2026-02-19 22:46:41 -08:00
vote.finishedAt = Date.now();
vote.error = true;
}
2026-02-22 01:20:19 -08:00
if (state.generation !== roundGeneration) {
return;
}
2026-02-19 22:46:41 -08:00
rerender();
}),
2026-02-22 17:22:59 -08:00
),
// 30-second viewer voting window
new Promise((r) => setTimeout(r, 30_000)),
]);
2026-02-22 01:20:19 -08:00
if (state.generation !== roundGeneration) {
continue;
}
2026-02-19 22:46:41 -08:00
// ── 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";
2026-02-19 23:51:56 -08:00
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;
}
2026-02-22 18:44:49 -08:00
// 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;
}
2026-02-19 22:46:41 -08:00
rerender();
2026-02-20 02:49:19 -08:00
await new Promise((r) => setTimeout(r, 5000));
2026-02-22 01:20:19 -08:00
if (state.generation !== roundGeneration) {
continue;
}
2026-02-19 22:46:41 -08:00
// Archive round
2026-02-20 00:28:48 -08:00
saveRound(round);
2026-02-19 22:46:41 -08:00
state.completed = [...state.completed, round];
state.active = null;
rerender();
}
state.done = true;
rerender();
}