diff --git a/.gitignore b/.gitignore index a14702c..66edbf5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store +*.sqlite diff --git a/db.ts b/db.ts new file mode 100644 index 0000000..91778ba --- /dev/null +++ b/db.ts @@ -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) + }; +} diff --git a/frontend.css b/frontend.css index c4f0d59..81a3d8b 100644 --- a/frontend.css +++ b/frontend.css @@ -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 { diff --git a/frontend.tsx b/frontend.tsx index 60060c3..37328ab 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -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 ( -
-
- R{round.num} - "{round.prompt}" -
-
- {winner ? <> won : Tie} -
-
- ); -} - function GameOver({ scores }: { scores: Record }) { 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 - {completed.length > 0 && ( -
-
PAST ROUNDS
-
- {[...completed].reverse().map(round => ( - - ))} -
-
- )} +
+ + 📚 View Past Games + + +
); } diff --git a/game.ts b/game.ts index cbb1eac..96e134e 100644 --- a/game.ts +++ b/game.ts @@ -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(); diff --git a/history.css b/history.css new file mode 100644 index 0000000..ade4652 --- /dev/null +++ b/history.css @@ -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; + } +} \ No newline at end of file diff --git a/history.html b/history.html new file mode 100644 index 0000000..9181b5a --- /dev/null +++ b/history.html @@ -0,0 +1,16 @@ + + + + + + Quipslop History + + + + + + +
+ + + \ No newline at end of file diff --git a/history.tsx b/history.tsx new file mode 100644 index 0000000..71ca0a1 --- /dev/null +++ b/history.tsx @@ -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 = { + "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 ( + + {logo && } + {model.name} + + ); +} + +// ── Components ────────────────────────────────────────────────────────────── + +function HistoryContestant({ + task, + votes, + voters +}: { + task: TaskInfo; + votes: number; + voters: Model[]; +}) { + const color = getColor(task.model.name); + return ( +
+
+ +
+
+ “{task.result}” +
+
+
+ {votes} {votes === 1 ? 'vote' : 'votes'} +
+
+ {voters.map(v => { + const logo = getLogo(v.name); + if (!logo) return null; + return {v.name}; + })} +
+
+
+ ); +} + +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 ( +
+
+
+
+ Prompted by +
+
+ {round.prompt} +
+
+
+
R{round.num}
+
+
+ +
+
+
+ + {isAWinner &&
WINNER
} +
+
“{round.answerTasks[0].result}”
+
+
+ {votesA} {votesA === 1 ? 'vote' : 'votes'} +
+
+ {votersA.map(v => getLogo(v.name) && )} +
+
+
+ +
+
+ + {isBWinner &&
WINNER
} +
+
“{round.answerTasks[1].result}”
+
+
+ {votesB} {votesB === 1 ? 'vote' : 'votes'} +
+
+ {votersB.map(v => getLogo(v.name) && )} +
+
+
+
+
+ ); +} + +// ── App ───────────────────────────────────────────────────────────────────── + +function App() { + const [rounds, setRounds] = useState([]); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+ QUIPSLOP + History +
+ +
+ +
+
Past Rounds
+ + {loading ? ( +
Loading...
+ ) : error ? ( +
{error}
+ ) : rounds.length === 0 ? ( +
No past rounds found.
+ ) : ( + <> +
+ {rounds.map(r => ( + + ))} +
+ + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ )} + + )} +
+
+ ); +} + +// ── Mount ─────────────────────────────────────────────────────────────────── + +const root = createRoot(document.getElementById("root")!); +root.render(); \ No newline at end of file diff --git a/server.ts b/server.ts index 5df4b3b..552c077 100644 --- a/server.ts +++ b/server.ts @@ -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) {