feat: convert to argument.es — Spanish, vote buttons, Docker

- Translate all ~430 prompts to Spanish with cultural adaptations
- Translate all UI strings (frontend, admin, history, broadcast)
- Translate AI system prompts; models now respond in Spanish
- Replace Twitch/Fossabot viewer voting with in-site vote buttons
- Add POST /api/vote endpoint (IP-based, supports vote switching)
- Vote buttons appear during voting phase with active state highlight
- Rename project to argument.es throughout (package.json, cookie, DB)
- Add docker-compose.yml with SQLite volume mount
- Add .env.sample documenting all required and optional vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 13:09:00 +01:00
parent ccaa86b4a6
commit 2abea42c18
16 changed files with 1124 additions and 1150 deletions

28
.env.sample Normal file
View File

@@ -0,0 +1,28 @@
# ── Required ──────────────────────────────────────────────────────────────────
# OpenRouter API key — get one at https://openrouter.ai/keys
OPENROUTER_API_KEY=sk-or-v1-...
# Admin panel password (choose something strong)
ADMIN_SECRET=change-me-please
# ── Optional ──────────────────────────────────────────────────────────────────
# Server port (default: 5109)
PORT=5109
# Path to the SQLite database file
# When using Docker, this is handled via the volume mount (/data/argumentes.sqlite)
DATABASE_PATH=argumentes.sqlite
# Rate limits (requests per minute)
HISTORY_LIMIT_PER_MIN=120
ADMIN_LIMIT_PER_MIN=10
# WebSocket limits
MAX_WS_GLOBAL=100000
MAX_WS_PER_IP=8
MAX_WS_NEW_PER_SEC=50
# Viewer vote broadcast debounce in ms (default: 250)
VIEWER_VOTE_BROADCAST_DEBOUNCE_MS=250

View File

@@ -1,9 +1,9 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="es">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>quipslop Admin</title> <title>argument.es — Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link

View File

@@ -192,7 +192,7 @@ function App() {
if (mode === "checking") { if (mode === "checking") {
return ( return (
<div className="admin admin--centered"> <div className="admin admin--centered">
<div className="loading">Checking admin session...</div> <div className="loading">Comprobando sesión de administrador...</div>
</div> </div>
); );
} }
@@ -202,12 +202,12 @@ function App() {
<div className="admin admin--centered"> <div className="admin admin--centered">
<main className="panel panel--login"> <main className="panel panel--login">
<a href="/" className="logo-link"> <a href="/" className="logo-link">
<img src="/assets/logo.svg" alt="quipslop" /> <img src="/assets/logo.svg" alt="argument.es" />
</a> </a>
<h1>Admin Access</h1> <h1>Acceso de administrador</h1>
<p className="muted"> <p className="muted">
Enter your passcode once. A secure cookie will keep this browser Introduce tu contraseña una vez. Una cookie segura mantendrá
logged in. esta sesión activa en el navegador.
</p> </p>
<form <form
@@ -218,7 +218,7 @@ function App() {
data-lpignore="true" data-lpignore="true"
> >
<label htmlFor="passcode" className="field-label"> <label htmlFor="passcode" className="field-label">
Passcode Contraseña
</label> </label>
<input <input
id="passcode" id="passcode"
@@ -239,15 +239,15 @@ function App() {
data-1p-ignore data-1p-ignore
data-lpignore="true" data-lpignore="true"
> >
{pending === "login" ? "Checking..." : "Unlock Admin"} {pending === "login" ? "Comprobando..." : "Desbloquear Admin"}
</button> </button>
</form> </form>
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
<div className="quick-links"> <div className="quick-links">
<a href="/">Live Game</a> <a href="/">Juego en vivo</a>
<a href="/history">History</a> <a href="/history">Historial</a>
</div> </div>
</main> </main>
</div> </div>
@@ -258,23 +258,23 @@ function App() {
<div className="admin"> <div className="admin">
<header className="admin-header"> <header className="admin-header">
<a href="/" className="logo-link"> <a href="/" className="logo-link">
quipslop argument.es
</a> </a>
<nav className="quick-links"> <nav className="quick-links">
<a href="/">Live Game</a> <a href="/">Juego en vivo</a>
<a href="/history">History</a> <a href="/history">Historial</a>
<button className="link-button" onClick={onLogout} disabled={busy}> <button className="link-button" onClick={onLogout} disabled={busy}>
Logout Cerrar sesión
</button> </button>
</nav> </nav>
</header> </header>
<main className="panel panel--main"> <main className="panel panel--main">
<div className="panel-head"> <div className="panel-head">
<h1>Admin Console</h1> <h1>Consola de administrador</h1>
<p> <p>
Pause/resume the game loop, export all data as JSON, or wipe all Pausa/reanuda el bucle del juego, exporta todos los datos en JSON
stored data. o borra todos los datos almacenados.
</p> </p>
</div> </div>
@@ -282,18 +282,18 @@ function App() {
<section className="status-grid" aria-live="polite"> <section className="status-grid" aria-live="polite">
<StatusCard <StatusCard
label="Engine" label="Motor"
value={snapshot?.isPaused ? "Paused" : "Running"} value={snapshot?.isPaused ? "En pausa" : "Ejecutándose"}
/> />
<StatusCard <StatusCard
label="Active Round" label="Ronda activa"
value={snapshot?.isRunningRound ? "In Progress" : "Idle"} value={snapshot?.isRunningRound ? "En curso" : "Inactivo"}
/> />
<StatusCard <StatusCard
label="Persisted Rounds" label="Rondas guardadas"
value={String(snapshot?.persistedRounds ?? 0)} value={String(snapshot?.persistedRounds ?? 0)}
/> />
<StatusCard label="Viewers" value={String(snapshot?.viewerCount ?? 0)} /> <StatusCard label="Espectadores" value={String(snapshot?.viewerCount ?? 0)} />
</section> </section>
<section className="actions" aria-label="Admin actions"> <section className="actions" aria-label="Admin actions">
@@ -303,7 +303,7 @@ function App() {
disabled={busy || Boolean(snapshot?.isPaused)} disabled={busy || Boolean(snapshot?.isPaused)}
onClick={() => runControl("/api/admin/pause", "pause")} onClick={() => runControl("/api/admin/pause", "pause")}
> >
{pending === "pause" ? "Pausing..." : "Pause"} {pending === "pause" ? "Pausando..." : "Pausar"}
</button> </button>
<button <button
type="button" type="button"
@@ -311,10 +311,10 @@ function App() {
disabled={busy || !snapshot?.isPaused} disabled={busy || !snapshot?.isPaused}
onClick={() => runControl("/api/admin/resume", "resume")} onClick={() => runControl("/api/admin/resume", "resume")}
> >
{pending === "resume" ? "Resuming..." : "Resume"} {pending === "resume" ? "Reanudando..." : "Reanudar"}
</button> </button>
<button type="button" className="btn" disabled={busy} onClick={onExport}> <button type="button" className="btn" disabled={busy} onClick={onExport}>
{pending === "export" ? "Exporting..." : "Export JSON"} {pending === "export" ? "Exportando..." : "Exportar JSON"}
</button> </button>
<button <button
type="button" type="button"
@@ -322,7 +322,7 @@ function App() {
disabled={busy} disabled={busy}
onClick={() => setIsResetOpen(true)} onClick={() => setIsResetOpen(true)}
> >
Reset Data Borrar datos
</button> </button>
</section> </section>
</main> </main>
@@ -330,13 +330,13 @@ function App() {
{isResetOpen && ( {isResetOpen && (
<div className="modal-backdrop" role="dialog" aria-modal="true"> <div className="modal-backdrop" role="dialog" aria-modal="true">
<div className="modal"> <div className="modal">
<h2>Reset all data?</h2> <h2>¿Borrar todos los datos?</h2>
<p> <p>
This permanently deletes every saved round and resets scores. Esto elimina permanentemente todas las rondas guardadas y
Current game flow is also paused. reinicia las puntuaciones. El juego también se pausará.
</p> </p>
<p> <p>
Type <code>{RESET_TOKEN}</code> to continue. Escribe <code>{RESET_TOKEN}</code> para continuar.
</p> </p>
<input <input
type="text" type="text"
@@ -356,7 +356,7 @@ function App() {
}} }}
disabled={busy} disabled={busy}
> >
Cancel Cancelar
</button> </button>
<button <button
type="button" type="button"
@@ -364,7 +364,7 @@ function App() {
onClick={onReset} onClick={onReset}
disabled={busy || resetText !== RESET_TOKEN} disabled={busy || resetText !== RESET_TOKEN}
> >
{pending === "reset" ? "Resetting..." : "Confirm Reset"} {pending === "reset" ? "Borrando..." : "Confirmar borrado"}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="es">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>quipslop Broadcast</title> <title>argument.es — Transmisión</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link

View File

@@ -291,7 +291,7 @@ function drawHeader() {
ctx.font = '700 40px "Inter", sans-serif'; ctx.font = '700 40px "Inter", sans-serif';
ctx.fillStyle = "#ededed"; ctx.fillStyle = "#ededed";
ctx.fillText("quipslop", 48, 76); ctx.fillText("argument.es", 48, 76);
} }

2
db.ts
View File

@@ -1,7 +1,7 @@
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import type { RoundState } from "./game.ts"; import type { RoundState } from "./game.ts";
const dbPath = process.env.DATABASE_PATH ?? "quipslop.sqlite"; const dbPath = process.env.DATABASE_PATH ?? "argumentes.sqlite";
export const db = new Database(dbPath, { create: true }); export const db = new Database(dbPath, { create: true });
db.exec(` db.exec(`

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
services:
argumentes:
build: .
container_name: argument.es
restart: unless-stopped
ports:
- "${PORT:-5109}:${PORT:-5109}"
environment:
- NODE_ENV=production
- PORT=${PORT:-5109}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
- ADMIN_SECRET=${ADMIN_SECRET}
- DATABASE_PATH=/data/argumentes.sqlite
- VIEWER_VOTE_BROADCAST_DEBOUNCE_MS=${VIEWER_VOTE_BROADCAST_DEBOUNCE_MS:-250}
- HISTORY_LIMIT_PER_MIN=${HISTORY_LIMIT_PER_MIN:-120}
- ADMIN_LIMIT_PER_MIN=${ADMIN_LIMIT_PER_MIN:-10}
- MAX_WS_GLOBAL=${MAX_WS_GLOBAL:-100000}
- MAX_WS_PER_IP=${MAX_WS_PER_IP:-8}
- MAX_WS_NEW_PER_SEC=${MAX_WS_NEW_PER_SEC:-50}
volumes:
- argumentes_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${PORT:-5109}/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
argumentes_data:

View File

@@ -138,8 +138,14 @@ body {
color: var(--text-dim); color: var(--text-dim);
} }
.vote-hint { .vote-panel {
margin: -10px 0 22px; margin: -10px 0 22px;
display: flex;
flex-direction: column;
gap: 10px;
}
.vote-panel__label {
font-family: var(--mono); font-family: var(--mono);
font-size: 11px; font-size: 11px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@@ -147,8 +153,40 @@ body {
color: var(--text-muted); color: var(--text-muted);
} }
.vote-hint strong { .vote-panel__buttons {
display: flex;
gap: 10px;
}
.vote-btn {
flex: 1;
padding: 10px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text); color: var(--text);
cursor: pointer;
font-family: var(--sans);
font-size: 13px;
transition: border-color 0.15s, background 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.vote-btn:hover {
border-color: #444;
background: #1a1a1a;
}
.vote-btn--active {
border-color: var(--accent);
background: rgba(217, 119, 87, 0.12);
}
.vote-btn--active:hover {
background: rgba(217, 119, 87, 0.18);
} }
/* ── Prompt ───────────────────────────────────────────────────── */ /* ── Prompt ───────────────────────────────────────────────────── */

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./frontend.css"; import "./frontend.css";
@@ -120,7 +120,7 @@ function PromptCard({ round }: { round: RoundState }) {
return ( return (
<div className="prompt"> <div className="prompt">
<div className="prompt__by"> <div className="prompt__by">
<ModelTag model={round.prompter} small /> is writing a prompt <ModelTag model={round.prompter} small /> está escribiendo una pregunta
<Dots /> <Dots />
</div> </div>
<div className="prompt__text prompt__text--loading"> <div className="prompt__text prompt__text--loading">
@@ -134,7 +134,7 @@ function PromptCard({ round }: { round: RoundState }) {
return ( return (
<div className="prompt"> <div className="prompt">
<div className="prompt__text prompt__text--error"> <div className="prompt__text prompt__text--error">
Prompt generation failed Error al generar la pregunta
</div> </div>
</div> </div>
); );
@@ -143,7 +143,7 @@ function PromptCard({ round }: { round: RoundState }) {
return ( return (
<div className="prompt"> <div className="prompt">
<div className="prompt__by"> <div className="prompt__by">
Prompted by <ModelTag model={round.prompter} small /> Pregunta de <ModelTag model={round.prompter} small />
</div> </div>
<div className="prompt__text">{round.prompt}</div> <div className="prompt__text">{round.prompt}</div>
</div> </div>
@@ -185,7 +185,7 @@ function ContestantCard({
> >
<div className="contestant__head"> <div className="contestant__head">
<ModelTag model={task.model} /> <ModelTag model={task.model} />
{isWinner && <span className="win-tag">WIN</span>} {isWinner && <span className="win-tag">GANA</span>}
</div> </div>
<div className="contestant__body"> <div className="contestant__body">
@@ -213,7 +213,7 @@ function ContestantCard({
{voteCount} {voteCount}
</span> </span>
<span className="vote-meta__label"> <span className="vote-meta__label">
vote{voteCount !== 1 ? "s" : ""} voto{voteCount !== 1 ? "s" : ""}
</span> </span>
<span className="vote-meta__dots"> <span className="vote-meta__dots">
{voters.map((v, i) => { {voters.map((v, i) => {
@@ -252,7 +252,7 @@ function ContestantCard({
{viewerVotes ?? 0} {viewerVotes ?? 0}
</span> </span>
<span className="vote-meta__label"> <span className="vote-meta__label">
viewer vote{(viewerVotes ?? 0) !== 1 ? "s" : ""} voto{(viewerVotes ?? 0) !== 1 ? "s" : ""} del público
</span> </span>
<span className="viewer-vote-meta__icon">👥</span> <span className="viewer-vote-meta__icon">👥</span>
</div> </div>
@@ -270,10 +270,14 @@ function Arena({
round, round,
total, total,
viewerVotingSecondsLeft, viewerVotingSecondsLeft,
myVote,
onVote,
}: { }: {
round: RoundState; round: RoundState;
total: number | null; total: number | null;
viewerVotingSecondsLeft: number; viewerVotingSecondsLeft: number;
myVote: "A" | "B" | null;
onVote: (side: "A" | "B") => void;
}) { }) {
const [contA, contB] = round.contestants; const [contA, contB] = round.contestants;
const showVotes = round.phase === "voting" || round.phase === "done"; const showVotes = round.phase === "voting" || round.phase === "done";
@@ -294,12 +298,12 @@ function Arena({
const phaseText = const phaseText =
round.phase === "prompting" round.phase === "prompting"
? "Writing prompt" ? "Generando pregunta"
: round.phase === "answering" : round.phase === "answering"
? "Answering" ? "Respondiendo"
: round.phase === "voting" : round.phase === "voting"
? "Judges voting" ? "Votando los jueces"
: "Complete"; : "Completado";
return ( return (
<div className="arena"> <div className="arena">
@@ -316,8 +320,22 @@ function Arena({
</span> </span>
</div> </div>
{showCountdown && ( {showCountdown && (
<div className="vote-hint"> <div className="vote-panel">
Vote in Twitch chat: <strong>1</strong> for left, <strong>2</strong> for right. <span className="vote-panel__label">¿Cuál es más gracioso?</span>
<div className="vote-panel__buttons">
<button
className={`vote-btn ${myVote === "A" ? "vote-btn--active" : ""}`}
onClick={() => onVote("A")}
>
<ModelTag model={contA} />
</button>
<button
className={`vote-btn ${myVote === "B" ? "vote-btn--active" : ""}`}
onClick={() => onVote("B")}
>
<ModelTag model={contB} />
</button>
</div>
</div> </div>
)} )}
@@ -349,7 +367,7 @@ function Arena({
)} )}
{isDone && votesA === votesB && totalVotes > 0 && ( {isDone && votesA === votesB && totalVotes > 0 && (
<div className="tie-label">Tie</div> <div className="tie-label">Empate</div>
)} )}
</div> </div>
); );
@@ -363,7 +381,7 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
return ( return (
<div className="game-over"> <div className="game-over">
<div className="game-over__label">Game Over</div> <div className="game-over__label">Fin del Juego</div>
{champion && champion[1] > 0 && ( {champion && champion[1] > 0 && (
<div className="game-over__winner"> <div className="game-over__winner">
<span className="game-over__crown">👑</span> <span className="game-over__crown">👑</span>
@@ -374,7 +392,7 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
{getLogo(champion[0]) && <img src={getLogo(champion[0])!} alt="" />} {getLogo(champion[0]) && <img src={getLogo(champion[0])!} alt="" />}
{champion[0]} {champion[0]}
</span> </span>
<span className="game-over__sub">is the funniest AI</span> <span className="game-over__sub">es la IA más graciosa</span>
</div> </div>
)} )}
</div> </div>
@@ -450,26 +468,26 @@ function Standings({
return ( return (
<aside className="standings"> <aside className="standings">
<div className="standings__head"> <div className="standings__head">
<span className="standings__title">Standings</span> <span className="standings__title">Clasificación</span>
<div className="standings__links"> <div className="standings__links">
<a href="/history" className="standings__link"> <a href="/history" className="standings__link">
History Historial
</a> </a>
<a href="https://twitch.tv/quipslop" target="_blank" rel="noopener noreferrer" className="standings__link"> <a href="https://twitch.tv/argument_es" target="_blank" rel="noopener noreferrer" className="standings__link">
Twitch Twitch
</a> </a>
<a href="https://github.com/T3-Content/quipslop" target="_blank" rel="noopener noreferrer" className="standings__link"> <a href="https://argument.es" target="_blank" rel="noopener noreferrer" className="standings__link">
GitHub Web
</a> </a>
</div> </div>
</div> </div>
<LeaderboardSection <LeaderboardSection
label="AI Judges" label="Jueces IA"
scores={scores} scores={scores}
competing={competing} competing={competing}
/> />
<LeaderboardSection <LeaderboardSection
label="Viewers" label="Público"
scores={viewerScores} scores={viewerScores}
competing={competing} competing={competing}
/> />
@@ -483,10 +501,10 @@ function ConnectingScreen() {
return ( return (
<div className="connecting"> <div className="connecting">
<div className="connecting__logo"> <div className="connecting__logo">
<img src="/assets/logo.svg" alt="quipslop" /> <img src="/assets/logo.svg" alt="argument.es" />
</div> </div>
<div className="connecting__sub"> <div className="connecting__sub">
Connecting Conectando
<Dots /> <Dots />
</div> </div>
</div> </div>
@@ -501,6 +519,8 @@ function App() {
const [viewerCount, setViewerCount] = useState(0); const [viewerCount, setViewerCount] = useState(0);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0); const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
const lastVotedRoundRef = useRef<number | null>(null);
// Countdown timer for viewer voting // Countdown timer for viewer voting
useEffect(() => { useEffect(() => {
@@ -519,6 +539,28 @@ function App() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [state?.active?.viewerVotingEndsAt, state?.active?.phase]); }, [state?.active?.viewerVotingEndsAt, state?.active?.phase]);
// Reset my vote when a new round starts
useEffect(() => {
const roundNum = state?.active?.num ?? null;
if (roundNum !== null && roundNum !== lastVotedRoundRef.current) {
setMyVote(null);
lastVotedRoundRef.current = roundNum;
}
}, [state?.active?.num]);
async function handleVote(side: "A" | "B") {
setMyVote(side);
try {
await fetch("/api/vote", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ side }),
});
} catch {
// ignore network errors
}
}
useEffect(() => { useEffect(() => {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.host}/ws`; const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
@@ -569,7 +611,7 @@ function App() {
<main className="main"> <main className="main">
<header className="header"> <header className="header">
<a href="/" className="logo"> <a href="/" className="logo">
<img src="/assets/logo.svg" alt="quipslop" /> <img src="/assets/logo.svg" alt="argument.es" />
</a> </a>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}> <div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
{state.isPaused && ( {state.isPaused && (
@@ -577,12 +619,12 @@ function App() {
className="viewer-pill" className="viewer-pill"
style={{ color: "var(--text-muted)", borderColor: "var(--border)" }} style={{ color: "var(--text-muted)", borderColor: "var(--border)" }}
> >
Paused En pausa
</div> </div>
)} )}
<div className="viewer-pill" aria-live="polite"> <div className="viewer-pill" aria-live="polite">
<span className="viewer-pill__dot" /> <span className="viewer-pill__dot" />
{viewerCount} viewer{viewerCount === 1 ? "" : "s"} watching {viewerCount} espectador{viewerCount === 1 ? "" : "es"} conectado{viewerCount === 1 ? "" : "s"}
</div> </div>
</div> </div>
</header> </header>
@@ -594,18 +636,20 @@ function App() {
round={displayRound} round={displayRound}
total={totalRounds} total={totalRounds}
viewerVotingSecondsLeft={viewerVotingSecondsLeft} viewerVotingSecondsLeft={viewerVotingSecondsLeft}
myVote={myVote}
onVote={handleVote}
/> />
) : ( ) : (
<div className="waiting"> <div className="waiting">
Starting Iniciando
<Dots /> <Dots />
</div> </div>
)} )}
{isNextPrompting && state.lastCompleted && ( {isNextPrompting && state.lastCompleted && (
<div className="next-toast"> <div className="next-toast">
<ModelTag model={state.active!.prompter} small /> is writing the <ModelTag model={state.active!.prompter} small /> está escribiendo
next prompt la siguiente pregunta
<Dots /> <Dots />
</div> </div>
)} )}

16
game.ts
View File

@@ -191,13 +191,13 @@ import { ALL_PROMPTS } from "./prompts";
function buildPromptSystem(): string { function buildPromptSystem(): string {
const examples = shuffle([...ALL_PROMPTS]).slice(0, 80); const examples = shuffle([...ALL_PROMPTS]).slice(0, 80);
return `You are a comedy writer for the game Quiplash. Generate a single funny fill-in-the-blank prompt that players will try to answer. The prompt should be surprising and designed to elicit hilarious responses. Return ONLY the prompt text, nothing else. Keep it short (under 15 words). 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).
Use a wide VARIETY of prompt formats. Do NOT always use "The worst thing to..." — mix it up! Here are examples of the range of styles: 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")} ${examples.map((p) => `- ${p}`).join("\n")}
Come up with something ORIGINAL — don't copy these examples.`; Crea algo ORIGINAL — no copies estos ejemplos. Responde SIEMPRE en español.`;
} }
export async function callGeneratePrompt(model: Model): Promise<string> { export async function callGeneratePrompt(model: Model): Promise<string> {
@@ -207,7 +207,7 @@ export async function callGeneratePrompt(model: Model): Promise<string> {
model: openrouter.chat(model.id), model: openrouter.chat(model.id),
system, system,
prompt: prompt:
"Generate a single original Quiplash prompt. Be creative and don't repeat common patterns.", "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", { log("INFO", `prompt:${model.name}`, "Raw response", {
@@ -227,8 +227,8 @@ export async function callGenerateAnswer(
}); });
const { text, usage, reasoning } = await generateText({ const { text, usage, reasoning } = await generateText({
model: openrouter.chat(model.id), model: openrouter.chat(model.id),
system: `You are playing Quiplash! You'll be given a fill-in-the-blank prompt. Give the FUNNIEST possible answer. Be creative, edgy, unexpected, and concise. Reply with ONLY your answer — no quotes, no explanation, no preamble. Keep it short (under 12 words). Keep it concise and witty.`, 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: `Fill in the blank: ${prompt}`, prompt: `Completa la frase: ${prompt}`,
}); });
log("INFO", `answer:${model.name}`, "Raw response", { log("INFO", `answer:${model.name}`, "Raw response", {
@@ -252,8 +252,8 @@ export async function callVote(
}); });
const { text, usage, reasoning } = await generateText({ const { text, usage, reasoning } = await generateText({
model: openrouter.chat(voter.id), model: openrouter.chat(voter.id),
system: `You are a judge in a comedy game. You'll see a fill-in-the-blank prompt and two answers. Pick which answer is FUNNIER. You MUST respond with exactly "A" or "B" — nothing else.`, 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: `Prompt: "${prompt}"\n\nAnswer A: "${a.answer}"\nAnswer B: "${b.answer}"\n\nWhich is funnier? Reply with just A or B.`, 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 }); log("INFO", `vote:${voter.name}`, "Raw response", { rawText: text, usage });

View File

@@ -1,9 +1,9 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="es">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>quipslop History</title> <title>argument.es — Historial</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link

View File

@@ -104,7 +104,7 @@ function HistoryContestant({
</div> </div>
<div className="history-contestant__votes"> <div className="history-contestant__votes">
<div className="history-contestant__score" style={{ color }}> <div className="history-contestant__score" style={{ color }}>
{votes} {votes === 1 ? "vote" : "votes"} {votes} {votes === 1 ? "voto" : "votos"}
</div> </div>
<div className="history-contestant__voters"> <div className="history-contestant__voters">
{voters.map((v) => { {voters.map((v) => {
@@ -164,7 +164,7 @@ function HistoryCard({ round }: { round: RoundState }) {
<div className="history-card__header"> <div className="history-card__header">
<div className="history-card__prompt-section"> <div className="history-card__prompt-section">
<div className="history-card__prompter"> <div className="history-card__prompter">
Prompted by <ModelName model={round.prompter} /> Pregunta de <ModelName model={round.prompter} />
</div> </div>
<div className="history-card__prompt">{round.prompt}</div> <div className="history-card__prompt">{round.prompt}</div>
</div> </div>
@@ -180,7 +180,7 @@ function HistoryCard({ round }: { round: RoundState }) {
<div className="history-contestant__header"> <div className="history-contestant__header">
<ModelName model={contA} /> <ModelName model={contA} />
{isAWinner && ( {isAWinner && (
<div className="history-contestant__winner-badge">WINNER</div> <div className="history-contestant__winner-badge">GANADOR</div>
)} )}
</div> </div>
<div className="history-contestant__answer"> <div className="history-contestant__answer">
@@ -191,7 +191,7 @@ function HistoryCard({ round }: { round: RoundState }) {
className="history-contestant__score" className="history-contestant__score"
style={{ color: getColor(contA.name) }} style={{ color: getColor(contA.name) }}
> >
{votesA} {votesA === 1 ? "vote" : "votes"} {votesA} {votesA === 1 ? "voto" : "votos"}
</div> </div>
<div className="history-contestant__voters"> <div className="history-contestant__voters">
{votersA.map( {votersA.map(
@@ -210,7 +210,7 @@ function HistoryCard({ round }: { round: RoundState }) {
{totalViewerVotes > 0 && ( {totalViewerVotes > 0 && (
<ViewerVotes <ViewerVotes
count={round.viewerVotesA ?? 0} count={round.viewerVotesA ?? 0}
label={`viewer vote${(round.viewerVotesA ?? 0) === 1 ? "" : "s"}`} label={`voto${(round.viewerVotesA ?? 0) === 1 ? "" : "s"} del público`}
/> />
)} )}
</div> </div>
@@ -221,7 +221,7 @@ function HistoryCard({ round }: { round: RoundState }) {
<div className="history-contestant__header"> <div className="history-contestant__header">
<ModelName model={contB} /> <ModelName model={contB} />
{isBWinner && ( {isBWinner && (
<div className="history-contestant__winner-badge">WINNER</div> <div className="history-contestant__winner-badge">GANADOR</div>
)} )}
</div> </div>
<div className="history-contestant__answer"> <div className="history-contestant__answer">
@@ -232,7 +232,7 @@ function HistoryCard({ round }: { round: RoundState }) {
className="history-contestant__score" className="history-contestant__score"
style={{ color: getColor(contB.name) }} style={{ color: getColor(contB.name) }}
> >
{votesB} {votesB === 1 ? "vote" : "votes"} {votesB} {votesB === 1 ? "voto" : "votos"}
</div> </div>
<div className="history-contestant__voters"> <div className="history-contestant__voters">
{votersB.map( {votersB.map(
@@ -251,7 +251,7 @@ function HistoryCard({ round }: { round: RoundState }) {
{totalViewerVotes > 0 && ( {totalViewerVotes > 0 && (
<ViewerVotes <ViewerVotes
count={round.viewerVotesB ?? 0} count={round.viewerVotesB ?? 0}
label={`viewer vote${(round.viewerVotesB ?? 0) === 1 ? "" : "s"}`} label={`voto${(round.viewerVotesB ?? 0) === 1 ? "" : "s"} del público`}
/> />
)} )}
</div> </div>
@@ -287,24 +287,24 @@ function App() {
return ( return (
<div className="app"> <div className="app">
<a href="/" className="main-logo"> <a href="/" className="main-logo">
quipslop argument.es
</a> </a>
<main className="main"> <main className="main">
<div className="page-header"> <div className="page-header">
<div className="page-title">Past Rounds</div> <div className="page-title">Rondas anteriores</div>
<div className="page-links"> <div className="page-links">
<a href="/" className="back-link"> <a href="/" className="back-link">
Back to Game Volver al juego
</a> </a>
</div> </div>
</div> </div>
{loading ? ( {loading ? (
<div className="loading">Loading...</div> <div className="loading">Cargando...</div>
) : error ? ( ) : error ? (
<div className="error">{error}</div> <div className="error">{error}</div>
) : rounds.length === 0 ? ( ) : rounds.length === 0 ? (
<div className="empty">No past rounds found.</div> <div className="empty">No se encontraron rondas anteriores.</div>
) : ( ) : (
<> <>
<div <div
@@ -326,7 +326,7 @@ function App() {
PREV PREV
</button> </button>
<span className="pagination__info"> <span className="pagination__info">
Page {page} of {totalPages} Página {page} de {totalPages}
</span> </span>
<button <button
className="pagination__btn" className="pagination__btn"

View File

@@ -1,9 +1,9 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="es">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>quipslop</title> <title>argument.es</title>
<link rel="icon" type="image/svg+xml" href="./public/assets/logo.svg" /> <link rel="icon" type="image/svg+xml" href="./public/assets/logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@@ -1,5 +1,5 @@
{ {
"name": "quipslop", "name": "argument.es",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"private": true, "private": true,

1726
prompts.ts

File diff suppressed because it is too large Load Diff

212
server.ts
View File

@@ -101,27 +101,11 @@ const MAX_HISTORY_CACHE_KEYS = parsePositiveInt(
process.env.MAX_HISTORY_CACHE_KEYS, process.env.MAX_HISTORY_CACHE_KEYS,
500, 500,
); );
const FOSSABOT_CHANNEL_LOGIN = (
process.env.FOSSABOT_CHANNEL_LOGIN ?? "quipslop"
).trim().toLowerCase();
const FOSSABOT_VOTE_SECRET = process.env.FOSSABOT_VOTE_SECRET ?? "";
const FOSSABOT_CHAT_CHANNEL_ID = (
process.env.FOSSABOT_CHAT_CHANNEL_ID ?? "813591620327550976"
).trim();
const FOSSABOT_SESSION_TOKEN = (process.env.FOSSABOT_SESSION_TOKEN ?? "").trim();
const FOSSABOT_VALIDATE_TIMEOUT_MS = parsePositiveInt(
process.env.FOSSABOT_VALIDATE_TIMEOUT_MS,
1_500,
);
const FOSSABOT_SEND_CHAT_TIMEOUT_MS = parsePositiveInt(
process.env.FOSSABOT_SEND_CHAT_TIMEOUT_MS,
3_000,
);
const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt( const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt(
process.env.VIEWER_VOTE_BROADCAST_DEBOUNCE_MS, process.env.VIEWER_VOTE_BROADCAST_DEBOUNCE_MS,
250, 250,
); );
const ADMIN_COOKIE = "quipslop_admin"; const ADMIN_COOKIE = "argumentes_admin";
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
const requestWindows = new Map<string, number[]>(); const requestWindows = new Map<string, number[]>();
@@ -282,109 +266,6 @@ function setHistoryCache(key: string, body: string, expiresAt: number) {
type ViewerVoteSide = "A" | "B"; type ViewerVoteSide = "A" | "B";
function isValidFossabotValidateUrl(rawUrl: string): boolean {
try {
const url = new URL(rawUrl);
return (
url.protocol === "https:" &&
url.host === "api.fossabot.com" &&
url.pathname.startsWith("/v2/customapi/validate/")
);
} catch {
return false;
}
}
async function validateFossabotRequest(validateUrl: string): Promise<boolean> {
if (!isValidFossabotValidateUrl(validateUrl)) {
return false;
}
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
FOSSABOT_VALIDATE_TIMEOUT_MS,
);
try {
const res = await fetch(validateUrl, {
method: "GET",
signal: controller.signal,
});
if (!res.ok) return false;
const body = (await res.json().catch(() => null)) as
| { context_url?: unknown }
| null;
return Boolean(body && typeof body.context_url === "string");
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
async function sendFossabotChatMessage(messageText: string): Promise<void> {
if (!FOSSABOT_SESSION_TOKEN) {
log(
"WARN",
"fossabot:chat",
"Skipped chat message because FOSSABOT_SESSION_TOKEN is not configured",
);
return;
}
if (!FOSSABOT_CHAT_CHANNEL_ID) {
log(
"WARN",
"fossabot:chat",
"Skipped chat message because FOSSABOT_CHAT_CHANNEL_ID is not configured",
);
return;
}
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
FOSSABOT_SEND_CHAT_TIMEOUT_MS,
);
try {
const url = `https://api.fossabot.com/v2/channels/${FOSSABOT_CHAT_CHANNEL_ID}/bot/send_chat_message`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${FOSSABOT_SESSION_TOKEN}`,
},
body: JSON.stringify({ messageText }),
signal: controller.signal,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
log("WARN", "fossabot:chat", "Fossabot send_chat_message failed", {
status: res.status,
body: body.slice(0, 250),
});
return;
}
const response = (await res.json().catch(() => null)) as
| { transactionId?: unknown }
| null;
log("INFO", "fossabot:chat", "Sent voting prompt to Twitch chat", {
transactionId:
response && typeof response.transactionId === "string"
? response.transactionId
: undefined,
});
} catch (error) {
log("WARN", "fossabot:chat", "Failed to send chat message", {
error: error instanceof Error ? error.message : String(error),
});
} finally {
clearTimeout(timeout);
}
}
function applyViewerVote(voterId: string, side: ViewerVoteSide): boolean { function applyViewerVote(voterId: string, side: ViewerVoteSide): boolean {
const round = gameState.active; const round = gameState.active;
@@ -508,77 +389,34 @@ const server = Bun.serve<WsData>({
return new Response("ok", { status: 200 }); return new Response("ok", { status: 200 });
} }
if ( if (url.pathname === "/api/vote") {
url.pathname === "/api/fossabot/vote/1" || if (req.method !== "POST") {
url.pathname === "/api/fossabot/vote/2" return new Response("Method Not Allowed", {
) {
if (req.method !== "GET") {
return new Response("", {
status: 405, status: 405,
headers: { Allow: "GET" }, headers: { Allow: "POST" },
}); });
} }
if (!FOSSABOT_VOTE_SECRET) {
log("ERROR", "vote:fossabot", "FOSSABOT_VOTE_SECRET is not configured"); let side: string = "";
return new Response("", { status: 503 }); try {
const body = await req.json();
side = String((body as Record<string, unknown>).side ?? "");
} catch {
return new Response("Invalid JSON body", { status: 400 });
} }
const providedSecret = url.searchParams.get("secret") ?? ""; if (side !== "A" && side !== "B") {
if (!providedSecret || !secureCompare(providedSecret, FOSSABOT_VOTE_SECRET)) { return new Response("Invalid side", { status: 400 });
log("WARN", "vote:fossabot", "Rejected due to missing/invalid secret", {
ip,
});
return new Response("", { status: 401 });
} }
const channelProvider = req.headers const applied = applyViewerVote(ip, side as ViewerVoteSide);
.get("x-fossabot-channelprovider")
?.trim()
.toLowerCase();
const channelLogin = req.headers
.get("x-fossabot-channellogin")
?.trim()
.toLowerCase();
if (channelProvider !== "twitch" || channelLogin !== FOSSABOT_CHANNEL_LOGIN) {
log("WARN", "vote:fossabot", "Rejected due to channel/provider mismatch", {
ip,
channelProvider,
channelLogin,
});
return new Response("", { status: 403 });
}
const validateUrl = req.headers.get("x-fossabot-validateurl") ?? "";
const isValid = await validateFossabotRequest(validateUrl);
if (!isValid) {
log("WARN", "vote:fossabot", "Validation check failed", { ip });
return new Response("", { status: 401 });
}
const userProvider = req.headers
.get("x-fossabot-message-userprovider")
?.trim()
.toLowerCase();
if (userProvider && userProvider !== "twitch") {
return new Response("", { status: 403 });
}
const userProviderId = req.headers
.get("x-fossabot-message-userproviderid")
?.trim();
if (!userProviderId) {
log("WARN", "vote:fossabot", "Missing user provider ID", { ip });
return new Response("", { status: 400 });
}
const votedFor: ViewerVoteSide = url.pathname.endsWith("/1") ? "A" : "B";
const applied = applyViewerVote(userProviderId, votedFor);
if (applied) { if (applied) {
scheduleViewerVoteBroadcast(); scheduleViewerVoteBroadcast();
} }
return new Response("", { return new Response(JSON.stringify({ ok: true }), {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store", "Cache-Control": "no-store",
}, },
}); });
@@ -681,7 +519,7 @@ const server = Bun.serve<WsData>({
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Cache-Control": "no-store", "Cache-Control": "no-store",
"Content-Disposition": `attachment; filename="quipslop-export-${Date.now()}.json"`, "Content-Disposition": `attachment; filename="argumentes-export-${Date.now()}.json"`,
}, },
}); });
} }
@@ -888,7 +726,7 @@ const server = Bun.serve<WsData>({
broadcastViewerCount(); broadcastViewerCount();
}, },
message() { message() {
// Viewer voting moved to Twitch chat via Fossabot. // Viewer voting handled via /api/vote endpoint.
}, },
close(ws) { close(ws) {
clients.delete(ws); clients.delete(ws);
@@ -917,9 +755,9 @@ const server = Bun.serve<WsData>({
}, },
}); });
console.log(`\n🎮 quipslop Web — http://localhost:${server.port}`); console.log(`\n🎮 argument.es Web — http://localhost:${server.port}`);
console.log(`📡 WebSocket — ws://localhost:${server.port}/ws`); console.log(`📡 WebSocket — ws://localhost:${server.port}/ws`);
console.log(`🎯 ${runs} rounds with ${MODELS.length} models\n`); console.log(`🎯 ${runs} rondas con ${MODELS.length} modelos\n`);
log("INFO", "server", `Web server started on port ${server.port}`, { log("INFO", "server", `Web server started on port ${server.port}`, {
runs, runs,
@@ -928,12 +766,8 @@ log("INFO", "server", `Web server started on port ${server.port}`, {
// ── Start game ────────────────────────────────────────────────────────────── // ── Start game ──────────────────────────────────────────────────────────────
runGame(runs, gameState, broadcast, (round) => { runGame(runs, gameState, broadcast, () => {
viewerVoters.clear(); viewerVoters.clear();
const [modelA, modelB] = round.contestants;
const messageText = `1 in chat for ${modelA.name}, 2 in chat for ${modelB.name}`;
void sendFossabotChatMessage(messageText);
}).then(() => { }).then(() => {
console.log(`\n✅ Game complete! Log: ${LOG_FILE}`); console.log(`\n✅ ¡Juego completado! Registro: ${LOG_FILE}`);
}); });