This commit is contained in:
Theo Browne
2026-02-20 00:28:48 -08:00
parent 53f0543e99
commit 6bd3718cad
9 changed files with 648 additions and 108 deletions

1
.gitignore vendored
View File

@@ -32,3 +32,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
*.sqlite

32
db.ts Normal file
View File

@@ -0,0 +1,32 @@
import { Database } from "bun:sqlite";
import type { RoundState } from "./game.ts";
export const db = new Database("quipslop.sqlite", { create: true });
db.exec(`
CREATE TABLE IF NOT EXISTS rounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
num INTEGER,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
data TEXT
);
`);
export function saveRound(round: RoundState) {
const insert = db.prepare("INSERT INTO rounds (num, data) VALUES ($num, $data)");
insert.run({ $num: round.num, $data: JSON.stringify(round) });
}
export function getRounds(page: number = 1, limit: number = 10) {
const offset = (page - 1) * limit;
const countQuery = db.query("SELECT COUNT(*) as count FROM rounds").get() as { count: number };
const rows = db.query("SELECT data FROM rounds ORDER BY id DESC LIMIT $limit OFFSET $offset")
.all({ $limit: limit, $offset: offset }) as { data: string }[];
return {
rounds: rows.map(r => JSON.parse(r.data) as RoundState),
total: countQuery.count,
page,
limit,
totalPages: Math.ceil(countQuery.count / limit)
};
}

View File

@@ -93,13 +93,38 @@ body {
.sidebar__section {
display: flex;
flex-direction: column;
}
.sidebar__section--standings {
flex: 1;
min-height: 0;
}
.sidebar__section--link {
border-top: 1px solid var(--border);
padding: 24px 32px;
flex-shrink: 0;
}
.sidebar__section--history {
flex: 1;
border-top: 1px solid var(--border);
min-height: 0;
.history-link {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
text-decoration: none;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 700;
transition: all 0.2s;
}
.history-link:hover {
background: var(--border);
border-color: var(--border-light);
}
.sidebar__header {
@@ -117,15 +142,8 @@ body {
display: flex;
flex-direction: column;
gap: 4px;
}
.sidebar__history-list {
flex: 1;
overflow-y: auto;
padding: 0 16px 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.standing {
@@ -219,67 +237,6 @@ body {
color: var(--text-muted);
}
/* ── Past Rounds Mini ─────────────────────────────────────────── */
.past-round-mini {
background: var(--surface-2);
border: 1px solid var(--border);
padding: 12px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.past-round-mini__top {
display: flex;
gap: 12px;
align-items: flex-start;
}
.past-round-mini__num {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
flex-shrink: 0;
}
.past-round-mini__prompt {
font-family: 'Inter', sans-serif;
font-size: 13px;
color: var(--text);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.past-round-mini__winner {
font-size: 12px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 6px;
padding-left: 28px;
}
.small-model-name {
font-size: 12px;
}
.small-model-name .model-logo {
width: 14px;
height: 14px;
}
.past-round-mini__tie {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
color: var(--text-muted);
}
/* ── Arena ─────────────────────────────────────────────────────── */
.arena {

View File

@@ -252,28 +252,6 @@ function Arena({ round, total }: { round: RoundState; total: number }) {
);
}
function PastRoundMini({ round }: { round: RoundState }) {
const [contA, contB] = round.contestants;
let votesA = 0, votesB = 0;
for (const v of round.votes) {
if (v.votedFor?.name === contA.name) votesA++;
else if (v.votedFor?.name === contB.name) votesB++;
}
const winner = votesA > votesB ? contA : votesB > votesA ? contB : null;
return (
<div className="past-round-mini">
<div className="past-round-mini__top">
<span className="past-round-mini__num">R{round.num}</span>
<span className="past-round-mini__prompt">"{round.prompt}"</span>
</div>
<div className="past-round-mini__winner">
{winner ? <><ModelName model={winner} showLogo={true} className="small-model-name" /> won</> : <span className="past-round-mini__tie">Tie</span>}
</div>
</div>
);
}
function GameOver({ scores }: { scores: Record<string, number> }) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const champion = sorted[0];
@@ -350,16 +328,12 @@ function Sidebar({ scores, activeRound, completed }: { scores: Record<string, nu
)}
</div>
{completed.length > 0 && (
<div className="sidebar__section sidebar__section--history">
<div className="sidebar__header">PAST ROUNDS</div>
<div className="sidebar__history-list">
{[...completed].reverse().map(round => (
<PastRoundMini key={round.num} round={round} />
))}
</div>
</div>
)}
<div className="sidebar__section sidebar__section--link">
<a href="/history" className="history-link">
<span>📚 View Past Games</span>
<span></span>
</a>
</div>
</aside>
);
}

View File

@@ -10,7 +10,7 @@ export const MODELS = [
{ id: "moonshotai/kimi-k2", name: "Kimi K2" },
// { id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
{ id: "deepseek/deepseek-v3.2", name: "DeepSeek 3.2" },
{ id: "z-ai/glm-5", name: "GLM-5" },
// { id: "z-ai/glm-5", name: "GLM-5" },
{ id: "openai/gpt-5.2", name: "GPT-5.2" },
{ id: "anthropic/claude-opus-4.6", name: "Opus 4.6" },
{ id: "anthropic/claude-sonnet-4.6", name: "Sonnet 4.6" },
@@ -249,6 +249,8 @@ export async function callVote(
return cleaned.startsWith("A") ? "A" : "B";
}
import { saveRound } from "./db.ts";
// ── Game loop ───────────────────────────────────────────────────────────────
export async function runGame(
@@ -391,6 +393,7 @@ export async function runGame(
await new Promise((r) => setTimeout(r, 2000));
// Archive round
saveRound(round);
state.completed = [...state.completed, round];
state.active = null;
rerender();

300
history.css Normal file
View File

@@ -0,0 +1,300 @@
/* ── Base ─────────────────────────────────────────────── */
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #050505;
--surface: #0a0a0a;
--surface-2: #141414;
--border: #222222;
--border-light: #333333;
--text: #ffffff;
--text-dim: #a1a1a1;
--text-muted: #555555;
--primary: #4285F4;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', -apple-system, sans-serif;
font-size: 15px;
line-height: 1.5;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* ── Header ───────────────────────────────────────────────────── */
.header {
padding: 24px 48px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
background: var(--bg);
position: sticky;
top: 0;
z-index: 10;
}
.header__left {
display: flex;
align-items: center;
gap: 24px;
}
.header__logo {
font-family: 'JetBrains Mono', monospace;
font-size: 24px;
font-weight: 700;
letter-spacing: -1px;
color: var(--text);
text-decoration: none;
}
.header__tagline {
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
}
.header__nav a {
color: var(--text-dim);
text-decoration: none;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
transition: color 0.2s;
}
.header__nav a:hover {
color: var(--text);
}
/* ── Main Layout ──────────────────────────────────────────────── */
.main {
max-width: 900px;
margin: 0 auto;
padding: 64px 24px;
display: flex;
flex-direction: column;
gap: 48px;
}
.page-title {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 700;
letter-spacing: 2px;
color: var(--text-dim);
text-transform: uppercase;
border-bottom: 1px solid var(--border);
padding-bottom: 16px;
margin-bottom: 16px;
}
/* ── Loading / Error ──────────────────────────────────────────── */
.loading, .error, .empty {
text-align: center;
padding: 64px 0;
font-family: 'JetBrains Mono', monospace;
color: var(--text-muted);
font-size: 14px;
letter-spacing: 1px;
}
.error { color: #ef4444; }
/* ── History Card ─────────────────────────────────────────────── */
.history-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px;
display: flex;
flex-direction: column;
gap: 24px;
}
.history-card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
}
.history-card__prompt-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.history-card__prompter {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
display: flex;
align-items: center;
gap: 8px;
}
.history-card__prompt {
font-family: 'DM Serif Display', serif;
font-size: 32px;
line-height: 1.2;
color: var(--text);
letter-spacing: -0.5px;
}
.history-card__meta {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--text-dim);
text-align: right;
flex-shrink: 0;
}
.history-card__showdown {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-top: 16px;
border-top: 1px solid var(--border);
padding-top: 24px;
}
.history-contestant {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
border-radius: 8px;
background: var(--surface-2);
border: 1px solid transparent;
}
.history-contestant--winner {
border-color: var(--border-light);
background: var(--bg);
box-shadow: inset 0 0 0 1px var(--border);
}
.history-contestant__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.history-contestant__winner-badge {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
background: var(--text);
color: var(--bg);
border-radius: 4px;
letter-spacing: 1px;
}
.history-contestant__answer {
font-family: 'DM Serif Display', serif;
font-size: 20px;
line-height: 1.3;
color: var(--text-dim);
}
.history-contestant--winner .history-contestant__answer {
color: var(--text);
}
.history-contestant__votes {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
padding-top: 16px;
border-top: 1px dashed var(--border);
}
.history-contestant__score {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.history-contestant__voters {
display: flex;
gap: 6px;
}
.voter-mini-logo {
width: 14px;
height: 14px;
border-radius: 2px;
}
/* ── Pagination ───────────────────────────────────────────────── */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 32px;
}
.pagination__btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 700;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.pagination__btn:hover:not(:disabled) {
background: var(--surface-2);
border-color: var(--border-light);
}
.pagination__btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination__info {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
color: var(--text-dim);
}
/* ── Utility ──────────────────────────────────────────────────── */
.model-name {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: 'Inter', sans-serif;
font-weight: 600;
font-size: 14px;
}
.model-logo {
width: 16px;
height: 16px;
object-fit: contain;
}
@media (max-width: 768px) {
.history-card__showdown {
grid-template-columns: 1fr;
}
}

16
history.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quipslop History</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;600;700;900&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="./history.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./history.tsx"></script>
</body>
</html>

248
history.tsx Normal file
View File

@@ -0,0 +1,248 @@
import React, { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";
import "./history.css";
// ── Types ───────────────────────────────────────────────────────────────────
type Model = { id: string; name: string };
type TaskInfo = { model: Model; startedAt: number; finishedAt?: number; result?: string; error?: string };
type VoteInfo = { voter: Model; startedAt: number; finishedAt?: number; votedFor?: Model; error?: boolean };
type RoundState = {
num: number;
phase: "prompting" | "answering" | "voting" | "done";
prompter: Model;
promptTask: TaskInfo;
prompt?: string;
contestants: [Model, Model];
answerTasks: [TaskInfo, TaskInfo];
votes: VoteInfo[];
scoreA?: number;
scoreB?: number;
};
// ── Shared UI Utils ─────────────────────────────────────────────────────────
const MODEL_COLORS: Record<string, string> = {
"Gemini 3.1 Pro": "#4285F4",
"Kimi K2": "#00E599",
"DeepSeek 3.2": "#4D6BFE",
"GLM-5": "#1F63EC",
"GPT-5.2": "#10A37F",
"Opus 4.6": "#D97757",
"Sonnet 4.6": "#D97757",
"Grok 4.1": "#FFFFFF",
};
function getColor(name: string): string {
return MODEL_COLORS[name] ?? "#A1A1A1";
}
function getLogo(name: string): string | null {
if (name.includes("Gemini")) return "/assets/logos/gemini.svg";
if (name.includes("Kimi")) return "/assets/logos/kimi.svg";
if (name.includes("DeepSeek")) return "/assets/logos/deepseek.svg";
if (name.includes("GLM")) return "/assets/logos/glm.svg";
if (name.includes("GPT")) return "/assets/logos/openai.svg";
if (name.includes("Opus") || name.includes("Sonnet")) return "/assets/logos/claude.svg";
if (name.includes("Grok")) return "/assets/logos/grok.svg";
return null;
}
function ModelName({ model, className = "" }: { model: Model; className?: string }) {
const logo = getLogo(model.name);
const color = getColor(model.name);
return (
<span className={`model-name ${className}`} style={{ color }}>
{logo && <img src={logo} alt="" className="model-logo" />}
{model.name}
</span>
);
}
// ── Components ──────────────────────────────────────────────────────────────
function HistoryContestant({
task,
votes,
voters
}: {
task: TaskInfo;
votes: number;
voters: Model[];
}) {
const color = getColor(task.model.name);
return (
<div className={`history-contestant`} style={{ borderColor: color }}>
<div className="history-contestant__header">
<ModelName model={task.model} />
</div>
<div className="history-contestant__answer">
&ldquo;{task.result}&rdquo;
</div>
<div className="history-contestant__votes">
<div className="history-contestant__score" style={{ color }}>
{votes} {votes === 1 ? 'vote' : 'votes'}
</div>
<div className="history-contestant__voters">
{voters.map(v => {
const logo = getLogo(v.name);
if (!logo) return null;
return <img key={v.name} src={logo} title={v.name} alt={v.name} className="voter-mini-logo" />;
})}
</div>
</div>
</div>
);
}
function HistoryCard({ round }: { round: RoundState }) {
const [contA, contB] = round.contestants;
let votesA = 0, votesB = 0;
const votersA: Model[] = [];
const votersB: Model[] = [];
for (const v of round.votes) {
if (v.votedFor?.name === contA.name) { votesA++; votersA.push(v.voter); }
else if (v.votedFor?.name === contB.name) { votesB++; votersB.push(v.voter); }
}
const isAWinner = votesA > votesB;
const isBWinner = votesB > votesA;
return (
<div className="history-card">
<div className="history-card__header">
<div className="history-card__prompt-section">
<div className="history-card__prompter">
Prompted by <ModelName model={round.prompter} />
</div>
<div className="history-card__prompt">
{round.prompt}
</div>
</div>
<div className="history-card__meta">
<div>R{round.num}</div>
</div>
</div>
<div className="history-card__showdown">
<div className={`history-contestant ${isAWinner ? "history-contestant--winner" : ""}`}>
<div className="history-contestant__header">
<ModelName model={contA} />
{isAWinner && <div className="history-contestant__winner-badge">WINNER</div>}
</div>
<div className="history-contestant__answer">&ldquo;{round.answerTasks[0].result}&rdquo;</div>
<div className="history-contestant__votes">
<div className="history-contestant__score" style={{ color: getColor(contA.name) }}>
{votesA} {votesA === 1 ? 'vote' : 'votes'}
</div>
<div className="history-contestant__voters">
{votersA.map(v => getLogo(v.name) && <img key={v.name} src={getLogo(v.name)!} title={v.name} className="voter-mini-logo" />)}
</div>
</div>
</div>
<div className={`history-contestant ${isBWinner ? "history-contestant--winner" : ""}`}>
<div className="history-contestant__header">
<ModelName model={contB} />
{isBWinner && <div className="history-contestant__winner-badge">WINNER</div>}
</div>
<div className="history-contestant__answer">&ldquo;{round.answerTasks[1].result}&rdquo;</div>
<div className="history-contestant__votes">
<div className="history-contestant__score" style={{ color: getColor(contB.name) }}>
{votesB} {votesB === 1 ? 'vote' : 'votes'}
</div>
<div className="history-contestant__voters">
{votersB.map(v => getLogo(v.name) && <img key={v.name} src={getLogo(v.name)!} title={v.name} className="voter-mini-logo" />)}
</div>
</div>
</div>
</div>
</div>
);
}
// ── App ─────────────────────────────────────────────────────────────────────
function App() {
const [rounds, setRounds] = useState<RoundState[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
fetch(`/api/history?page=${page}`)
.then(res => res.json())
.then(data => {
setRounds(data.rounds);
setTotalPages(data.totalPages || 1);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [page]);
return (
<div className="app">
<header className="header">
<div className="header__left">
<a href="/" className="header__logo">QUIPSLOP</a>
<span className="header__tagline">History</span>
</div>
<nav className="header__nav">
<a href="/"> Back to Game</a>
</nav>
</header>
<main className="main">
<div className="page-title">Past Rounds</div>
{loading ? (
<div className="loading">Loading...</div>
) : error ? (
<div className="error">{error}</div>
) : rounds.length === 0 ? (
<div className="empty">No past rounds found.</div>
) : (
<>
<div className="history-list" style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
{rounds.map(r => (
<HistoryCard key={r.num + "-" + Math.random()} round={r} />
))}
</div>
{totalPages > 1 && (
<div className="pagination">
<button
className="pagination__btn"
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
>
PREV
</button>
<span className="pagination__info">Page {page} of {totalPages}</span>
<button
className="pagination__btn"
disabled={page === totalPages}
onClick={() => setPage(p => p + 1)}
>
NEXT
</button>
</div>
)}
</>
)}
</main>
</div>
);
}
// ── Mount ───────────────────────────────────────────────────────────────────
const root = createRoot(document.getElementById("root")!);
root.render(<App />);

View File

@@ -1,5 +1,7 @@
import type { ServerWebSocket } from "bun";
import index from "./index.html";
import indexHtml from "./index.html";
import historyHtml from "./history.html";
import { getRounds } from "./db.ts";
import {
MODELS,
LOG_FILE,
@@ -39,12 +41,13 @@ function broadcast() {
// ── Server ──────────────────────────────────────────────────────────────────
const port = parseInt(process.env.PORT ?? "3000", 10);
const port = parseInt(process.env.PORT ?? "5109", 10); // 5109 = SLOP
const server = Bun.serve({
port,
routes: {
"/": index,
"/": indexHtml,
"/history": historyHtml,
},
fetch(req, server) {
const url = new URL(req.url);
@@ -53,6 +56,12 @@ const server = Bun.serve({
const file = Bun.file(path);
return new Response(file);
}
if (url.pathname === "/api/history") {
const page = parseInt(url.searchParams.get("page") || "1", 10);
return new Response(JSON.stringify(getRounds(page)), {
headers: { "Content-Type": "application/json" }
});
}
if (url.pathname === "/ws") {
const upgraded = server.upgrade(req);
if (!upgraded) {