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 ;
2026-02-27 14:03:30 +01:00
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 ) ;
2026-02-27 13:09:00 +01:00
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
2026-02-27 13:09:00 +01: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
2026-02-27 13:09:00 +01: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 :
2026-02-27 13:09:00 +01:00
"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 ) ,
2026-02-27 13:09:00 +01:00
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 ) ,
2026-02-27 13:09:00 +01:00
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" ;
}
2026-02-27 14:03:30 +01:00
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 {
2026-02-27 14:03:30 +01:00
// 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 ( ) ;
}