Compare commits
12 Commits
d80c40ed3f
...
2abea42c18
| Author | SHA1 | Date | |
|---|---|---|---|
| 2abea42c18 | |||
|
|
ccaa86b4a6 | ||
|
|
79f9dab7fb | ||
|
|
f33277a095 | ||
|
|
af2f055939 | ||
|
|
41deee807a | ||
|
|
8f52bee72b | ||
|
|
8489927b67 | ||
|
|
0295041cda | ||
|
|
eda80110c6 | ||
|
|
0dcb6f71ab | ||
|
|
ba543c1f25 |
28
.env.sample
Normal file
28
.env.sample
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
64
admin.tsx
64
admin.tsx
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
100
broadcast.ts
100
broadcast.ts
@@ -32,6 +32,7 @@ type GameState = {
|
|||||||
lastCompleted: RoundState | null;
|
lastCompleted: RoundState | null;
|
||||||
active: RoundState | null;
|
active: RoundState | null;
|
||||||
scores: Record<string, number>;
|
scores: Record<string, number>;
|
||||||
|
viewerScores: Record<string, number>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
generation: number;
|
generation: number;
|
||||||
@@ -290,13 +291,65 @@ 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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawScoreboard(scores: Record<string, number>) {
|
function drawScoreboardSection(
|
||||||
const entries = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
entries: [string, number][],
|
||||||
|
label: string,
|
||||||
|
startY: number,
|
||||||
|
entryHeight: number,
|
||||||
|
) {
|
||||||
|
const maxScore = entries[0]?.[1] || 1;
|
||||||
|
|
||||||
|
// Section label
|
||||||
|
ctx.font = '700 13px "JetBrains Mono", monospace';
|
||||||
|
ctx.fillStyle = "#555";
|
||||||
|
ctx.fillText(label, WIDTH - 348, startY);
|
||||||
|
|
||||||
|
// Divider line under label
|
||||||
|
ctx.fillStyle = "#1c1c1c";
|
||||||
|
ctx.fillRect(WIDTH - 348, startY + 8, 296, 1);
|
||||||
|
|
||||||
|
entries.forEach(([name, score], index) => {
|
||||||
|
const y = startY + 20 + index * entryHeight;
|
||||||
|
const color = getColor(name);
|
||||||
|
const pct = maxScore > 0 ? score / maxScore : 0;
|
||||||
|
|
||||||
|
ctx.font = '600 16px "JetBrains Mono", monospace';
|
||||||
|
ctx.fillStyle = "#555";
|
||||||
|
const rank = index === 0 && score > 0 ? "👑" : String(index + 1);
|
||||||
|
ctx.fillText(rank, WIDTH - 348, y + 18);
|
||||||
|
|
||||||
|
ctx.font = '600 16px "Inter", sans-serif';
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
const nameText = name.length > 18 ? `${name.slice(0, 18)}...` : name;
|
||||||
|
|
||||||
|
const drewLogo = drawModelLogo(name, WIDTH - 310, y + 4, 20);
|
||||||
|
if (drewLogo) {
|
||||||
|
ctx.fillText(nameText, WIDTH - 310 + 26, y + 18);
|
||||||
|
} else {
|
||||||
|
ctx.fillText(nameText, WIDTH - 310, y + 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
roundRect(WIDTH - 310, y + 30, 216, 3, 2, "#1c1c1c");
|
||||||
|
if (pct > 0) {
|
||||||
|
roundRect(WIDTH - 310, y + 30, Math.max(6, 216 * pct), 3, 2, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.font = '700 16px "JetBrains Mono", monospace';
|
||||||
|
ctx.fillStyle = "#666";
|
||||||
|
const scoreText = String(score);
|
||||||
|
const scoreWidth = ctx.measureText(scoreText).width;
|
||||||
|
ctx.fillText(scoreText, WIDTH - 48 - scoreWidth, y + 18);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawScoreboard(scores: Record<string, number>, viewerScores: Record<string, number>) {
|
||||||
|
const modelEntries = Object.entries(scores).sort((a, b) => b[1] - a[1]) as [string, number][];
|
||||||
|
const viewerEntries = Object.entries(viewerScores).sort((a, b) => b[1] - a[1]) as [string, number][];
|
||||||
|
|
||||||
roundRect(WIDTH - 380, 0, 380, HEIGHT, 0, "#111");
|
roundRect(WIDTH - 380, 0, 380, HEIGHT, 0, "#111");
|
||||||
ctx.fillStyle = "#1c1c1c";
|
ctx.fillStyle = "#1c1c1c";
|
||||||
ctx.fillRect(WIDTH - 380, 0, 1, HEIGHT);
|
ctx.fillRect(WIDTH - 380, 0, 1, HEIGHT);
|
||||||
@@ -305,40 +358,11 @@ function drawScoreboard(scores: Record<string, number>) {
|
|||||||
ctx.fillStyle = "#888";
|
ctx.fillStyle = "#888";
|
||||||
ctx.fillText("STANDINGS", WIDTH - 348, 76);
|
ctx.fillText("STANDINGS", WIDTH - 348, 76);
|
||||||
|
|
||||||
const maxScore = entries[0]?.[1] || 1;
|
const entryHeight = 52;
|
||||||
|
drawScoreboardSection(modelEntries, "AI JUDGES", 110, entryHeight);
|
||||||
|
|
||||||
entries.slice(0, 10).forEach(([name, score], index) => {
|
const viewerStartY = 110 + 28 + modelEntries.length * entryHeight + 16;
|
||||||
const y = 140 + index * 68;
|
drawScoreboardSection(viewerEntries, "VIEWERS", viewerStartY, entryHeight);
|
||||||
const color = getColor(name);
|
|
||||||
const pct = maxScore > 0 ? (score / maxScore) : 0;
|
|
||||||
|
|
||||||
ctx.font = '600 20px "JetBrains Mono", monospace';
|
|
||||||
ctx.fillStyle = "#888";
|
|
||||||
const rank = index === 0 && score > 0 ? "👑" : String(index + 1);
|
|
||||||
ctx.fillText(rank, WIDTH - 348, y + 24);
|
|
||||||
|
|
||||||
ctx.font = '600 20px "Inter", sans-serif';
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
const nameText = name.length > 18 ? `${name.slice(0, 18)}...` : name;
|
|
||||||
|
|
||||||
const drewLogo = drawModelLogo(name, WIDTH - 304, y + 6, 24);
|
|
||||||
if (drewLogo) {
|
|
||||||
ctx.fillText(nameText, WIDTH - 304 + 32, y + 24);
|
|
||||||
} else {
|
|
||||||
ctx.fillText(nameText, WIDTH - 304, y + 24);
|
|
||||||
}
|
|
||||||
|
|
||||||
roundRect(WIDTH - 304, y + 42, 208, 4, 2, "#1c1c1c");
|
|
||||||
if (pct > 0) {
|
|
||||||
roundRect(WIDTH - 304, y + 42, Math.max(8, 208 * pct), 4, 2, color);
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.font = '700 20px "JetBrains Mono", monospace';
|
|
||||||
ctx.fillStyle = "#888";
|
|
||||||
const scoreText = String(score);
|
|
||||||
const scoreWidth = ctx.measureText(scoreText).width;
|
|
||||||
ctx.fillText(scoreText, WIDTH - 48 - scoreWidth, y + 24);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawRound(round: RoundState) {
|
function drawRound(round: RoundState) {
|
||||||
@@ -608,7 +632,7 @@ function draw() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
drawScoreboard(state.scores);
|
drawScoreboard(state.scores, state.viewerScores ?? {});
|
||||||
|
|
||||||
const isNextPrompting = state.active?.phase === "prompting" && !state.active.prompt;
|
const isNextPrompting = state.active?.phase === "prompting" && !state.active.prompt;
|
||||||
const displayRound = isNextPrompting && state.lastCompleted ? state.lastCompleted : (state.active ?? state.lastCompleted ?? null);
|
const displayRound = isNextPrompting && state.lastCompleted ? state.lastCompleted : (state.active ?? state.lastCompleted ?? null);
|
||||||
|
|||||||
2
db.ts
2
db.ts
@@ -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
30
docker-compose.yml
Normal 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:
|
||||||
143
frontend.css
143
frontend.css
@@ -138,6 +138,57 @@ body {
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vote-panel {
|
||||||
|
margin: -10px 0 22px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-panel__label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
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 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.prompt {
|
.prompt {
|
||||||
@@ -404,8 +455,8 @@ body {
|
|||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 20px;
|
||||||
max-height: 220px;
|
max-height: 50vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -442,56 +493,87 @@ body {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.standings__list {
|
/* ── Leaderboard Section ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lb-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.standing {
|
.lb-section__head {
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-section__label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-section__list {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 10px;
|
|
||||||
padding: 5px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.standing--active {
|
/* ── Leaderboard Entry ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lb-entry {
|
||||||
|
padding: 6px 0 4px;
|
||||||
|
opacity: 0.55;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-entry--active,
|
||||||
|
.lb-entry:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.standing__rank {
|
.lb-entry__top {
|
||||||
width: 22px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-entry__rank {
|
||||||
|
width: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.standing__bar {
|
.lb-entry__score {
|
||||||
flex: 1;
|
margin-left: auto;
|
||||||
height: 3px;
|
|
||||||
background: var(--border);
|
|
||||||
border-radius: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
min-width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standing__fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.standing__score {
|
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
min-width: 16px;
|
min-width: 14px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lb-entry__bar {
|
||||||
|
margin-left: 26px;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-entry__fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Connecting ───────────────────────────────────────────────── */
|
/* ── Connecting ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
.connecting {
|
.connecting {
|
||||||
@@ -707,5 +789,6 @@ body {
|
|||||||
max-height: none;
|
max-height: none;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
gap: 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
245
frontend.tsx
245
frontend.tsx
@@ -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";
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ type GameState = {
|
|||||||
lastCompleted: RoundState | null;
|
lastCompleted: RoundState | null;
|
||||||
active: RoundState | null;
|
active: RoundState | null;
|
||||||
scores: Record<string, number>;
|
scores: Record<string, number>;
|
||||||
|
viewerScores: Record<string, number>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
generation: number;
|
generation: number;
|
||||||
@@ -53,8 +54,7 @@ type ViewerCountMessage = {
|
|||||||
type: "viewerCount";
|
type: "viewerCount";
|
||||||
viewerCount: number;
|
viewerCount: number;
|
||||||
};
|
};
|
||||||
type VotedAckMessage = { type: "votedAck"; votedFor: "A" | "B" };
|
type ServerMessage = StateMessage | ViewerCountMessage;
|
||||||
type ServerMessage = StateMessage | ViewerCountMessage | VotedAckMessage;
|
|
||||||
|
|
||||||
// ── Model colors & logos ─────────────────────────────────────────────────────
|
// ── Model colors & logos ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -161,9 +161,6 @@ function ContestantCard({
|
|||||||
voters,
|
voters,
|
||||||
viewerVotes,
|
viewerVotes,
|
||||||
totalViewerVotes,
|
totalViewerVotes,
|
||||||
votable,
|
|
||||||
onVote,
|
|
||||||
isMyVote,
|
|
||||||
}: {
|
}: {
|
||||||
task: TaskInfo;
|
task: TaskInfo;
|
||||||
voteCount: number;
|
voteCount: number;
|
||||||
@@ -173,9 +170,6 @@ function ContestantCard({
|
|||||||
voters: VoteInfo[];
|
voters: VoteInfo[];
|
||||||
viewerVotes?: number;
|
viewerVotes?: number;
|
||||||
totalViewerVotes?: number;
|
totalViewerVotes?: number;
|
||||||
votable?: boolean;
|
|
||||||
onVote?: () => void;
|
|
||||||
isMyVote?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const color = getColor(task.model.name);
|
const color = getColor(task.model.name);
|
||||||
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
|
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
|
||||||
@@ -186,17 +180,12 @@ function ContestantCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`contestant ${isWinner ? "contestant--winner" : ""} ${votable ? "contestant--votable" : ""} ${isMyVote ? "contestant--my-vote" : ""}`}
|
className={`contestant ${isWinner ? "contestant--winner" : ""}`}
|
||||||
style={{ "--accent": color } as React.CSSProperties}
|
style={{ "--accent": color } as React.CSSProperties}
|
||||||
onClick={votable ? onVote : undefined}
|
|
||||||
role={votable ? "button" : undefined}
|
|
||||||
tabIndex={votable ? 0 : undefined}
|
|
||||||
onKeyDown={votable ? (e) => { if (e.key === "Enter" || e.key === " ") onVote?.(); } : undefined}
|
|
||||||
>
|
>
|
||||||
<div className="contestant__head">
|
<div className="contestant__head">
|
||||||
<ModelTag model={task.model} />
|
<ModelTag model={task.model} />
|
||||||
{isMyVote && !isWinner && <span className="my-vote-tag">YOUR PICK</span>}
|
{isWinner && <span className="win-tag">GANA</span>}
|
||||||
{isWinner && <span className="win-tag">WIN</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="contestant__body">
|
<div className="contestant__body">
|
||||||
@@ -224,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) => {
|
||||||
@@ -263,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>
|
||||||
@@ -280,15 +269,15 @@ function ContestantCard({
|
|||||||
function Arena({
|
function Arena({
|
||||||
round,
|
round,
|
||||||
total,
|
total,
|
||||||
|
viewerVotingSecondsLeft,
|
||||||
myVote,
|
myVote,
|
||||||
onVote,
|
onVote,
|
||||||
viewerVotingSecondsLeft,
|
|
||||||
}: {
|
}: {
|
||||||
round: RoundState;
|
round: RoundState;
|
||||||
total: number | null;
|
total: number | null;
|
||||||
|
viewerVotingSecondsLeft: number;
|
||||||
myVote: "A" | "B" | null;
|
myVote: "A" | "B" | null;
|
||||||
onVote: (side: "A" | "B") => void;
|
onVote: (side: "A" | "B") => void;
|
||||||
viewerVotingSecondsLeft: number;
|
|
||||||
}) {
|
}) {
|
||||||
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";
|
||||||
@@ -305,22 +294,16 @@ function Arena({
|
|||||||
const votersB = round.votes.filter((v) => v.votedFor?.name === contB.name);
|
const votersB = round.votes.filter((v) => v.votedFor?.name === contB.name);
|
||||||
const totalViewerVotes = (round.viewerVotesA ?? 0) + (round.viewerVotesB ?? 0);
|
const totalViewerVotes = (round.viewerVotesA ?? 0) + (round.viewerVotesB ?? 0);
|
||||||
|
|
||||||
const canVote =
|
|
||||||
round.phase === "voting" &&
|
|
||||||
viewerVotingSecondsLeft > 0 &&
|
|
||||||
round.answerTasks[0].finishedAt &&
|
|
||||||
round.answerTasks[1].finishedAt;
|
|
||||||
|
|
||||||
const showCountdown = round.phase === "voting" && viewerVotingSecondsLeft > 0;
|
const showCountdown = round.phase === "voting" && viewerVotingSecondsLeft > 0;
|
||||||
|
|
||||||
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">
|
||||||
@@ -336,6 +319,25 @@ function Arena({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{showCountdown && (
|
||||||
|
<div className="vote-panel">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<PromptCard round={round} />
|
<PromptCard round={round} />
|
||||||
|
|
||||||
@@ -350,9 +352,6 @@ function Arena({
|
|||||||
voters={votersA}
|
voters={votersA}
|
||||||
viewerVotes={round.viewerVotesA}
|
viewerVotes={round.viewerVotesA}
|
||||||
totalViewerVotes={totalViewerVotes}
|
totalViewerVotes={totalViewerVotes}
|
||||||
votable={!!canVote}
|
|
||||||
onVote={() => onVote("A")}
|
|
||||||
isMyVote={myVote === "A"}
|
|
||||||
/>
|
/>
|
||||||
<ContestantCard
|
<ContestantCard
|
||||||
task={round.answerTasks[1]}
|
task={round.answerTasks[1]}
|
||||||
@@ -363,15 +362,12 @@ function Arena({
|
|||||||
voters={votersB}
|
voters={votersB}
|
||||||
viewerVotes={round.viewerVotesB}
|
viewerVotes={round.viewerVotesB}
|
||||||
totalViewerVotes={totalViewerVotes}
|
totalViewerVotes={totalViewerVotes}
|
||||||
votable={!!canVote}
|
|
||||||
onVote={() => onVote("B")}
|
|
||||||
isMyVote={myVote === "B"}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isDone && votesA === votesB && totalVotes > 0 && (
|
{isDone && votesA === votesB && totalVotes > 0 && (
|
||||||
<div className="tie-label">Tie</div>
|
<div className="tie-label">Empate</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -385,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>
|
||||||
@@ -396,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>
|
||||||
@@ -405,16 +401,63 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
|
|||||||
|
|
||||||
// ── Standings ────────────────────────────────────────────────────────────────
|
// ── Standings ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Standings({
|
function LeaderboardSection({
|
||||||
|
label,
|
||||||
scores,
|
scores,
|
||||||
activeRound,
|
competing,
|
||||||
}: {
|
}: {
|
||||||
|
label: string;
|
||||||
scores: Record<string, number>;
|
scores: Record<string, number>;
|
||||||
activeRound: RoundState | null;
|
competing: Set<string>;
|
||||||
}) {
|
}) {
|
||||||
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
||||||
const maxScore = sorted[0]?.[1] || 1;
|
const maxScore = sorted[0]?.[1] || 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lb-section">
|
||||||
|
<div className="lb-section__head">
|
||||||
|
<span className="lb-section__label">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lb-section__list">
|
||||||
|
{sorted.map(([name, score], i) => {
|
||||||
|
const pct = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0;
|
||||||
|
const color = getColor(name);
|
||||||
|
const active = competing.has(name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className={`lb-entry ${active ? "lb-entry--active" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="lb-entry__top">
|
||||||
|
<span className="lb-entry__rank">
|
||||||
|
{i === 0 && score > 0 ? "👑" : i + 1}
|
||||||
|
</span>
|
||||||
|
<ModelTag model={{ id: name, name }} small />
|
||||||
|
<span className="lb-entry__score">{score}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lb-entry__bar">
|
||||||
|
<div
|
||||||
|
className="lb-entry__fill"
|
||||||
|
style={{ width: `${pct}%`, background: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Standings({
|
||||||
|
scores,
|
||||||
|
viewerScores,
|
||||||
|
activeRound,
|
||||||
|
}: {
|
||||||
|
scores: Record<string, number>;
|
||||||
|
viewerScores: Record<string, number>;
|
||||||
|
activeRound: RoundState | null;
|
||||||
|
}) {
|
||||||
const competing = activeRound
|
const competing = activeRound
|
||||||
? new Set([
|
? new Set([
|
||||||
activeRound.contestants[0].name,
|
activeRound.contestants[0].name,
|
||||||
@@ -425,44 +468,29 @@ 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>
|
||||||
<div className="standings__list">
|
<LeaderboardSection
|
||||||
{sorted.map(([name, score], i) => {
|
label="Jueces IA"
|
||||||
const pct = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0;
|
scores={scores}
|
||||||
const color = getColor(name);
|
competing={competing}
|
||||||
const active = competing.has(name);
|
/>
|
||||||
return (
|
<LeaderboardSection
|
||||||
<div
|
label="Público"
|
||||||
key={name}
|
scores={viewerScores}
|
||||||
className={`standing ${active ? "standing--active" : ""}`}
|
competing={competing}
|
||||||
>
|
/>
|
||||||
<span className="standing__rank">
|
|
||||||
{i === 0 && score > 0 ? "👑" : i + 1}
|
|
||||||
</span>
|
|
||||||
<ModelTag model={{ id: name, name }} small />
|
|
||||||
<div className="standing__bar">
|
|
||||||
<div
|
|
||||||
className="standing__fill"
|
|
||||||
style={{ width: `${pct}%`, background: color }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="standing__score">{score}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -473,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>
|
||||||
@@ -490,19 +518,9 @@ function App() {
|
|||||||
const [totalRounds, setTotalRounds] = useState<number | null>(null);
|
const [totalRounds, setTotalRounds] = useState<number | null>(null);
|
||||||
const [viewerCount, setViewerCount] = useState(0);
|
const [viewerCount, setViewerCount] = useState(0);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
|
|
||||||
const [votedRound, setVotedRound] = useState<number | null>(null);
|
|
||||||
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
|
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
|
||||||
const wsRef = React.useRef<WebSocket | null>(null);
|
const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
|
||||||
|
const lastVotedRoundRef = useRef<number | null>(null);
|
||||||
// Reset vote when round changes
|
|
||||||
useEffect(() => {
|
|
||||||
const currentRound = state?.active?.num ?? null;
|
|
||||||
if (currentRound !== null && currentRound !== votedRound) {
|
|
||||||
setMyVote(null);
|
|
||||||
setVotedRound(null);
|
|
||||||
}
|
|
||||||
}, [state?.active?.num, votedRound]);
|
|
||||||
|
|
||||||
// Countdown timer for viewer voting
|
// Countdown timer for viewer voting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -521,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`;
|
||||||
@@ -530,11 +570,9 @@ function App() {
|
|||||||
let knownVersion: string | null = null;
|
let knownVersion: string | null = null;
|
||||||
function connect() {
|
function connect() {
|
||||||
ws = new WebSocket(wsUrl);
|
ws = new WebSocket(wsUrl);
|
||||||
wsRef.current = ws;
|
|
||||||
ws.onopen = () => setConnected(true);
|
ws.onopen = () => setConnected(true);
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
wsRef.current = null;
|
|
||||||
reconnectTimer = setTimeout(connect, 2000);
|
reconnectTimer = setTimeout(connect, 2000);
|
||||||
};
|
};
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
@@ -549,8 +587,6 @@ function App() {
|
|||||||
setViewerCount(msg.viewerCount);
|
setViewerCount(msg.viewerCount);
|
||||||
} else if (msg.type === "viewerCount") {
|
} else if (msg.type === "viewerCount") {
|
||||||
setViewerCount(msg.viewerCount);
|
setViewerCount(msg.viewerCount);
|
||||||
} else if (msg.type === "votedAck") {
|
|
||||||
setMyVote(msg.votedFor);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -562,13 +598,6 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleVote = (side: "A" | "B") => {
|
|
||||||
if (myVote === side || !wsRef.current) return;
|
|
||||||
wsRef.current.send(JSON.stringify({ type: "vote", votedFor: side }));
|
|
||||||
setMyVote(side);
|
|
||||||
setVotedRound(state?.active?.num ?? null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!connected || !state) return <ConnectingScreen />;
|
if (!connected || !state) return <ConnectingScreen />;
|
||||||
|
|
||||||
const isNextPrompting =
|
const isNextPrompting =
|
||||||
@@ -582,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 && (
|
||||||
@@ -590,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>
|
||||||
@@ -606,27 +635,27 @@ function App() {
|
|||||||
<Arena
|
<Arena
|
||||||
round={displayRound}
|
round={displayRound}
|
||||||
total={totalRounds}
|
total={totalRounds}
|
||||||
|
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
|
||||||
myVote={myVote}
|
myVote={myVote}
|
||||||
onVote={handleVote}
|
onVote={handleVote}
|
||||||
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Standings scores={state.scores} activeRound={state.active} />
|
<Standings scores={state.scores} viewerScores={state.viewerScores ?? {}} activeRound={state.active} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
29
game.ts
29
game.ts
@@ -73,6 +73,7 @@ export type GameState = {
|
|||||||
completed: RoundState[];
|
completed: RoundState[];
|
||||||
active: RoundState | null;
|
active: RoundState | null;
|
||||||
scores: Record<string, number>;
|
scores: Record<string, number>;
|
||||||
|
viewerScores: Record<string, number>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
generation: number;
|
generation: number;
|
||||||
@@ -190,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> {
|
||||||
@@ -206,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", {
|
||||||
@@ -226,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", {
|
||||||
@@ -251,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 });
|
||||||
@@ -271,7 +272,7 @@ export async function runGame(
|
|||||||
runs: number,
|
runs: number,
|
||||||
state: GameState,
|
state: GameState,
|
||||||
rerender: () => void,
|
rerender: () => void,
|
||||||
onViewerVotingStart?: () => void,
|
onViewerVotingStart?: (round: RoundState) => void,
|
||||||
) {
|
) {
|
||||||
let startRound = 1;
|
let startRound = 1;
|
||||||
const lastCompletedRound = state.completed.at(-1);
|
const lastCompletedRound = state.completed.at(-1);
|
||||||
@@ -402,7 +403,7 @@ export async function runGame(
|
|||||||
round.viewerVotesA = 0;
|
round.viewerVotesA = 0;
|
||||||
round.viewerVotesB = 0;
|
round.viewerVotesB = 0;
|
||||||
round.viewerVotingEndsAt = Date.now() + 30_000;
|
round.viewerVotingEndsAt = Date.now() + 30_000;
|
||||||
onViewerVotingStart?.();
|
onViewerVotingStart?.(round);
|
||||||
rerender();
|
rerender();
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -471,6 +472,14 @@ export async function runGame(
|
|||||||
} else if (votesB > votesA) {
|
} else if (votesB > votesA) {
|
||||||
state.scores[contB.name] = (state.scores[contB.name] || 0) + 1;
|
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();
|
rerender();
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 5000));
|
await new Promise((r) => setTimeout(r, 5000));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
28
history.tsx
28
history.tsx
@@ -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"
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
1726
prompts.ts
File diff suppressed because it is too large
Load Diff
11
quipslop.tsx
11
quipslop.tsx
@@ -223,11 +223,12 @@ function Game({ runs }: { runs: number }) {
|
|||||||
const stateRef = useRef<GameState>({
|
const stateRef = useRef<GameState>({
|
||||||
completed: [],
|
completed: [],
|
||||||
active: null,
|
active: null,
|
||||||
scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
|
scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
|
||||||
done: false,
|
viewerScores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
|
||||||
isPaused: false,
|
done: false,
|
||||||
generation: 0,
|
isPaused: false,
|
||||||
});
|
generation: 0,
|
||||||
|
});
|
||||||
const [, setTick] = useState(0);
|
const [, setTick] = useState(0);
|
||||||
const rerender = useCallback(() => setTick((t) => t + 1), []);
|
const rerender = useCallback(() => setTick((t) => t + 1), []);
|
||||||
|
|
||||||
|
|||||||
174
server.ts
174
server.ts
@@ -30,6 +30,7 @@ if (!process.env.OPENROUTER_API_KEY) {
|
|||||||
|
|
||||||
const allRounds = getAllRounds();
|
const allRounds = getAllRounds();
|
||||||
const initialScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
|
const initialScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
|
||||||
|
const initialViewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
|
||||||
|
|
||||||
let initialCompleted: RoundState[] = [];
|
let initialCompleted: RoundState[] = [];
|
||||||
if (allRounds.length > 0) {
|
if (allRounds.length > 0) {
|
||||||
@@ -43,6 +44,15 @@ if (allRounds.length > 0) {
|
|||||||
(initialScores[round.contestants[1].name] || 0) + 1;
|
(initialScores[round.contestants[1].name] || 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const vvA = round.viewerVotesA ?? 0;
|
||||||
|
const vvB = round.viewerVotesB ?? 0;
|
||||||
|
if (vvA > vvB) {
|
||||||
|
initialViewerScores[round.contestants[0].name] =
|
||||||
|
(initialViewerScores[round.contestants[0].name] || 0) + 1;
|
||||||
|
} else if (vvB > vvA) {
|
||||||
|
initialViewerScores[round.contestants[1].name] =
|
||||||
|
(initialViewerScores[round.contestants[1].name] || 0) + 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const lastRound = allRounds[allRounds.length - 1];
|
const lastRound = allRounds[allRounds.length - 1];
|
||||||
if (lastRound) {
|
if (lastRound) {
|
||||||
@@ -54,6 +64,7 @@ const gameState: GameState = {
|
|||||||
completed: initialCompleted,
|
completed: initialCompleted,
|
||||||
active: null,
|
active: null,
|
||||||
scores: initialScores,
|
scores: initialScores,
|
||||||
|
viewerScores: initialViewerScores,
|
||||||
done: false,
|
done: false,
|
||||||
isPaused: false,
|
isPaused: false,
|
||||||
generation: 0,
|
generation: 0,
|
||||||
@@ -74,6 +85,9 @@ const ADMIN_LIMIT_PER_MIN = parsePositiveInt(
|
|||||||
);
|
);
|
||||||
const MAX_WS_GLOBAL = parsePositiveInt(process.env.MAX_WS_GLOBAL, 100_000);
|
const MAX_WS_GLOBAL = parsePositiveInt(process.env.MAX_WS_GLOBAL, 100_000);
|
||||||
const MAX_WS_PER_IP = parsePositiveInt(process.env.MAX_WS_PER_IP, 8);
|
const MAX_WS_PER_IP = parsePositiveInt(process.env.MAX_WS_PER_IP, 8);
|
||||||
|
const MAX_WS_NEW_PER_SEC = parsePositiveInt(process.env.MAX_WS_NEW_PER_SEC, 50);
|
||||||
|
let wsNewConnections = 0;
|
||||||
|
let wsNewConnectionsResetAt = Date.now() + 1000;
|
||||||
const MAX_HISTORY_PAGE = parsePositiveInt(
|
const MAX_HISTORY_PAGE = parsePositiveInt(
|
||||||
process.env.MAX_HISTORY_PAGE,
|
process.env.MAX_HISTORY_PAGE,
|
||||||
100_000,
|
100_000,
|
||||||
@@ -87,7 +101,11 @@ const MAX_HISTORY_CACHE_KEYS = parsePositiveInt(
|
|||||||
process.env.MAX_HISTORY_CACHE_KEYS,
|
process.env.MAX_HISTORY_CACHE_KEYS,
|
||||||
500,
|
500,
|
||||||
);
|
);
|
||||||
const ADMIN_COOKIE = "quipslop_admin";
|
const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt(
|
||||||
|
process.env.VIEWER_VOTE_BROADCAST_DEBOUNCE_MS,
|
||||||
|
250,
|
||||||
|
);
|
||||||
|
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[]>();
|
||||||
@@ -101,21 +119,41 @@ function parsePositiveInt(value: string | undefined, fallback: number): number {
|
|||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClientIp(req: Request, server: Bun.Server<WsData>): string {
|
function isPrivateIp(ip: string): boolean {
|
||||||
// Railway's edge proxy strips client-provided X-Real-IP and sets the actual
|
const v4 = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
||||||
// client IP. All traffic goes through the edge proxy — it cannot be bypassed.
|
if (v4 === "127.0.0.1" || ip === "::1") return true;
|
||||||
// As a fallback, use the rightmost X-Forwarded-For value (the one Railway
|
if (v4.startsWith("10.")) return true;
|
||||||
// appends), then Bun's requestIP (which sees the proxy IP on Railway).
|
if (v4.startsWith("192.168.")) return true;
|
||||||
const realIp = req.headers.get("x-real-ip")?.trim();
|
// CGNAT range (RFC 6598) — used by Railway's internal proxy
|
||||||
if (realIp) return realIp;
|
if (v4.startsWith("100.")) {
|
||||||
|
const second = parseInt(v4.split(".")[1] ?? "", 10);
|
||||||
|
if (second >= 64 && second <= 127) return true;
|
||||||
|
}
|
||||||
|
if (ip.startsWith("fc") || ip.startsWith("fd")) return true;
|
||||||
|
if (v4.startsWith("172.")) {
|
||||||
|
const second = parseInt(v4.split(".")[1] ?? "", 10);
|
||||||
|
if (second >= 16 && second <= 31) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const xff = req.headers.get("x-forwarded-for");
|
function getClientIp(req: Request, server: Bun.Server<WsData>): string {
|
||||||
if (xff) {
|
const socketIp = server.requestIP(req)?.address ?? "unknown";
|
||||||
const rightmost = xff.split(",").at(-1)?.trim();
|
|
||||||
if (rightmost) return rightmost;
|
// Only trust proxy headers when the direct connection comes from
|
||||||
|
// a private IP (i.e. Railway's edge proxy). Direct public connections
|
||||||
|
// cannot spoof their IP this way.
|
||||||
|
if (socketIp !== "unknown" && isPrivateIp(socketIp)) {
|
||||||
|
const xff = req.headers.get("x-forwarded-for");
|
||||||
|
if (xff) {
|
||||||
|
const rightmost = xff.split(",").at(-1)?.trim();
|
||||||
|
if (rightmost && !isPrivateIp(rightmost)) {
|
||||||
|
return rightmost.startsWith("::ffff:") ? rightmost.slice(7) : rightmost;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.requestIP(req)?.address ?? "unknown";
|
return socketIp.startsWith("::ffff:") ? socketIp.slice(7) : socketIp;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRateLimited(key: string, limit: number, windowMs: number): boolean {
|
function isRateLimited(key: string, limit: number, windowMs: number): boolean {
|
||||||
@@ -226,6 +264,35 @@ function setHistoryCache(key: string, body: string, expiresAt: number) {
|
|||||||
historyCache.set(key, { body, expiresAt });
|
historyCache.set(key, { body, expiresAt });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ViewerVoteSide = "A" | "B";
|
||||||
|
|
||||||
|
|
||||||
|
function applyViewerVote(voterId: string, side: ViewerVoteSide): boolean {
|
||||||
|
const round = gameState.active;
|
||||||
|
if (!round || round.phase !== "voting") return false;
|
||||||
|
if (!round.viewerVotingEndsAt || Date.now() > round.viewerVotingEndsAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousVote = viewerVoters.get(voterId);
|
||||||
|
if (previousVote === side) return false;
|
||||||
|
|
||||||
|
// Undo previous vote if this viewer switched sides.
|
||||||
|
if (previousVote === "A") {
|
||||||
|
round.viewerVotesA = Math.max(0, (round.viewerVotesA ?? 0) - 1);
|
||||||
|
} else if (previousVote === "B") {
|
||||||
|
round.viewerVotesB = Math.max(0, (round.viewerVotesB ?? 0) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
viewerVoters.set(voterId, side);
|
||||||
|
if (side === "A") {
|
||||||
|
round.viewerVotesA = (round.viewerVotesA ?? 0) + 1;
|
||||||
|
} else {
|
||||||
|
round.viewerVotesB = (round.viewerVotesB ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── WebSocket clients ───────────────────────────────────────────────────────
|
// ── WebSocket clients ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const clients = new Set<ServerWebSocket<WsData>>();
|
const clients = new Set<ServerWebSocket<WsData>>();
|
||||||
@@ -237,7 +304,7 @@ function scheduleViewerVoteBroadcast() {
|
|||||||
viewerVoteBroadcastTimer = setTimeout(() => {
|
viewerVoteBroadcastTimer = setTimeout(() => {
|
||||||
viewerVoteBroadcastTimer = null;
|
viewerVoteBroadcastTimer = null;
|
||||||
broadcast();
|
broadcast();
|
||||||
}, 5_000);
|
}, VIEWER_VOTE_BROADCAST_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClientState() {
|
function getClientState() {
|
||||||
@@ -245,6 +312,7 @@ function getClientState() {
|
|||||||
active: gameState.active,
|
active: gameState.active,
|
||||||
lastCompleted: gameState.completed.at(-1) ?? null,
|
lastCompleted: gameState.completed.at(-1) ?? null,
|
||||||
scores: gameState.scores,
|
scores: gameState.scores,
|
||||||
|
viewerScores: gameState.viewerScores,
|
||||||
done: gameState.done,
|
done: gameState.done,
|
||||||
isPaused: gameState.isPaused,
|
isPaused: gameState.isPaused,
|
||||||
generation: gameState.generation,
|
generation: gameState.generation,
|
||||||
@@ -321,6 +389,39 @@ const server = Bun.serve<WsData>({
|
|||||||
return new Response("ok", { status: 200 });
|
return new Response("ok", { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/vote") {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response("Method Not Allowed", {
|
||||||
|
status: 405,
|
||||||
|
headers: { Allow: "POST" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let side: string = "";
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
side = String((body as Record<string, unknown>).side ?? "");
|
||||||
|
} catch {
|
||||||
|
return new Response("Invalid JSON body", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (side !== "A" && side !== "B") {
|
||||||
|
return new Response("Invalid side", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const applied = applyViewerVote(ip, side as ViewerVoteSide);
|
||||||
|
if (applied) {
|
||||||
|
scheduleViewerVoteBroadcast();
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/admin/login") {
|
if (url.pathname === "/api/admin/login") {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return new Response("Method Not Allowed", {
|
return new Response("Method Not Allowed", {
|
||||||
@@ -418,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"`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -455,6 +556,7 @@ const server = Bun.serve<WsData>({
|
|||||||
gameState.completed = [];
|
gameState.completed = [];
|
||||||
gameState.active = null;
|
gameState.active = null;
|
||||||
gameState.scores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
|
gameState.scores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
|
||||||
|
gameState.viewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
|
||||||
gameState.done = false;
|
gameState.done = false;
|
||||||
gameState.isPaused = true;
|
gameState.isPaused = true;
|
||||||
gameState.generation += 1;
|
gameState.generation += 1;
|
||||||
@@ -561,6 +663,14 @@ const server = Bun.serve<WsData>({
|
|||||||
headers: { Allow: "GET" },
|
headers: { Allow: "GET" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (now >= wsNewConnectionsResetAt) {
|
||||||
|
wsNewConnections = 0;
|
||||||
|
wsNewConnectionsResetAt = now + 1000;
|
||||||
|
}
|
||||||
|
if (wsNewConnections >= MAX_WS_NEW_PER_SEC) {
|
||||||
|
return new Response("Too Many Requests", { status: 429 });
|
||||||
|
}
|
||||||
if (clients.size >= MAX_WS_GLOBAL) {
|
if (clients.size >= MAX_WS_GLOBAL) {
|
||||||
log("WARN", "ws", "Global WS limit reached, rejecting", {
|
log("WARN", "ws", "Global WS limit reached, rejecting", {
|
||||||
ip,
|
ip,
|
||||||
@@ -584,6 +694,7 @@ const server = Bun.serve<WsData>({
|
|||||||
log("WARN", "ws", "WebSocket upgrade failed", { ip });
|
log("WARN", "ws", "WebSocket upgrade failed", { ip });
|
||||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||||
}
|
}
|
||||||
|
wsNewConnections++;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,31 +725,8 @@ const server = Bun.serve<WsData>({
|
|||||||
// Notify everyone else with just the viewer count
|
// Notify everyone else with just the viewer count
|
||||||
broadcastViewerCount();
|
broadcastViewerCount();
|
||||||
},
|
},
|
||||||
message(ws, message) {
|
message() {
|
||||||
try {
|
// Viewer voting handled via /api/vote endpoint.
|
||||||
const msg = JSON.parse(String(message));
|
|
||||||
if (msg.type !== "vote") return;
|
|
||||||
|
|
||||||
const round = gameState.active;
|
|
||||||
if (!round || round.phase !== "voting") return;
|
|
||||||
if (!round.viewerVotingEndsAt || Date.now() > round.viewerVotingEndsAt) return;
|
|
||||||
if (msg.votedFor !== "A" && msg.votedFor !== "B") return;
|
|
||||||
|
|
||||||
const ip = ws.data.ip;
|
|
||||||
const previousVote = viewerVoters.get(ip);
|
|
||||||
if (previousVote === msg.votedFor) return; // same vote, ignore
|
|
||||||
|
|
||||||
// Undo previous vote if changing
|
|
||||||
if (previousVote === "A") round.viewerVotesA = Math.max(0, (round.viewerVotesA ?? 0) - 1);
|
|
||||||
else if (previousVote === "B") round.viewerVotesB = Math.max(0, (round.viewerVotesB ?? 0) - 1);
|
|
||||||
|
|
||||||
viewerVoters.set(ip, msg.votedFor);
|
|
||||||
if (msg.votedFor === "A") round.viewerVotesA = (round.viewerVotesA ?? 0) + 1;
|
|
||||||
else round.viewerVotesB = (round.viewerVotesB ?? 0) + 1;
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: "votedAck", votedFor: msg.votedFor }));
|
|
||||||
scheduleViewerVoteBroadcast();
|
|
||||||
} catch {}
|
|
||||||
},
|
},
|
||||||
close(ws) {
|
close(ws) {
|
||||||
clients.delete(ws);
|
clients.delete(ws);
|
||||||
@@ -667,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,
|
||||||
@@ -681,5 +769,5 @@ log("INFO", "server", `Web server started on port ${server.port}`, {
|
|||||||
runGame(runs, gameState, broadcast, () => {
|
runGame(runs, gameState, broadcast, () => {
|
||||||
viewerVoters.clear();
|
viewerVoters.clear();
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
console.log(`\n✅ Game complete! Log: ${LOG_FILE}`);
|
console.log(`\n✅ ¡Juego completado! Registro: ${LOG_FILE}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user