This commit is contained in:
Theo Browne
2026-02-22 00:23:35 -08:00
parent a25097cd4a
commit ea9d844f4b
6 changed files with 374 additions and 150 deletions

View File

@@ -5,8 +5,20 @@ import "./frontend.css";
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
type Model = { id: string; name: string }; type Model = { id: string; name: string };
type TaskInfo = { model: Model; startedAt: number; finishedAt?: number; result?: string; error?: string }; type TaskInfo = {
type VoteInfo = { voter: Model; startedAt: number; finishedAt?: number; votedFor?: Model; error?: boolean }; model: Model;
startedAt: number;
finishedAt?: number;
result?: string;
error?: string;
};
type VoteInfo = {
voter: Model;
startedAt: number;
finishedAt?: number;
votedFor?: Model;
error?: boolean;
};
type RoundState = { type RoundState = {
num: number; num: number;
phase: "prompting" | "answering" | "voting" | "done"; phase: "prompting" | "answering" | "voting" | "done";
@@ -19,8 +31,18 @@ type RoundState = {
scoreA?: number; scoreA?: number;
scoreB?: number; scoreB?: number;
}; };
type GameState = { completed: RoundState[]; active: RoundState | null; scores: Record<string, number>; done: boolean }; type GameState = {
type ServerMessage = { type: "state"; data: GameState; totalRounds: number; viewerCount: number }; completed: RoundState[];
active: RoundState | null;
scores: Record<string, number>;
done: boolean;
};
type ServerMessage = {
type: "state";
data: GameState;
totalRounds: number;
viewerCount: number;
};
// ── Model colors & logos ───────────────────────────────────────────────────── // ── Model colors & logos ─────────────────────────────────────────────────────
@@ -46,7 +68,8 @@ function getLogo(name: string): string | null {
if (name.includes("DeepSeek")) return "/assets/logos/deepseek.svg"; if (name.includes("DeepSeek")) return "/assets/logos/deepseek.svg";
if (name.includes("GLM")) return "/assets/logos/glm.svg"; if (name.includes("GLM")) return "/assets/logos/glm.svg";
if (name.includes("GPT")) return "/assets/logos/openai.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("Opus") || name.includes("Sonnet"))
return "/assets/logos/claude.svg";
if (name.includes("Grok")) return "/assets/logos/grok.svg"; if (name.includes("Grok")) return "/assets/logos/grok.svg";
if (name.includes("MiniMax")) return "/assets/logos/minimax.svg"; if (name.includes("MiniMax")) return "/assets/logos/minimax.svg";
return null; return null;
@@ -55,14 +78,23 @@ function getLogo(name: string): string | null {
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
function Dots() { function Dots() {
return <span className="dots"><span>.</span><span>.</span><span>.</span></span>; return (
<span className="dots">
<span>.</span>
<span>.</span>
<span>.</span>
</span>
);
} }
function ModelTag({ model, small }: { model: Model; small?: boolean }) { function ModelTag({ model, small }: { model: Model; small?: boolean }) {
const logo = getLogo(model.name); const logo = getLogo(model.name);
const color = getColor(model.name); const color = getColor(model.name);
return ( return (
<span className={`model-tag ${small ? "model-tag--sm" : ""}`} style={{ color }}> <span
className={`model-tag ${small ? "model-tag--sm" : ""}`}
style={{ color }}
>
{logo && <img src={logo} alt="" className="model-tag__logo" />} {logo && <img src={logo} alt="" className="model-tag__logo" />}
{model.name} {model.name}
</span> </span>
@@ -76,9 +108,12 @@ 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<Dots /> <ModelTag model={round.prompter} small /> is writing a prompt
<Dots />
</div>
<div className="prompt__text prompt__text--loading">
<Dots />
</div> </div>
<div className="prompt__text prompt__text--loading"><Dots /></div>
</div> </div>
); );
} }
@@ -86,14 +121,18 @@ function PromptCard({ round }: { round: RoundState }) {
if (round.promptTask.error) { if (round.promptTask.error) {
return ( return (
<div className="prompt"> <div className="prompt">
<div className="prompt__text prompt__text--error">Prompt generation failed</div> <div className="prompt__text prompt__text--error">
Prompt generation failed
</div>
</div> </div>
); );
} }
return ( return (
<div className="prompt"> <div className="prompt">
<div className="prompt__by">Prompted by <ModelTag model={round.prompter} small /></div> <div className="prompt__by">
Prompted by <ModelTag model={round.prompter} small />
</div>
<div className="prompt__text">{round.prompt}</div> <div className="prompt__text">{round.prompt}</div>
</div> </div>
); );
@@ -102,7 +141,12 @@ function PromptCard({ round }: { round: RoundState }) {
// ── Contestant ─────────────────────────────────────────────────────────────── // ── Contestant ───────────────────────────────────────────────────────────────
function ContestantCard({ function ContestantCard({
task, voteCount, totalVotes, isWinner, showVotes, voters, task,
voteCount,
totalVotes,
isWinner,
showVotes,
voters,
}: { }: {
task: TaskInfo; task: TaskInfo;
voteCount: number; voteCount: number;
@@ -126,7 +170,9 @@ function ContestantCard({
<div className="contestant__body"> <div className="contestant__body">
{!task.finishedAt ? ( {!task.finishedAt ? (
<p className="answer answer--loading"><Dots /></p> <p className="answer answer--loading">
<Dots />
</p>
) : task.error ? ( ) : task.error ? (
<p className="answer answer--error">{task.error}</p> <p className="answer answer--error">{task.error}</p>
) : ( ) : (
@@ -137,18 +183,36 @@ function ContestantCard({
{showVotes && ( {showVotes && (
<div className="contestant__foot"> <div className="contestant__foot">
<div className="vote-bar"> <div className="vote-bar">
<div className="vote-bar__fill" style={{ width: `${pct}%`, background: color }} /> <div
className="vote-bar__fill"
style={{ width: `${pct}%`, background: color }}
/>
</div> </div>
<div className="vote-meta"> <div className="vote-meta">
<span className="vote-meta__count" style={{ color }}>{voteCount}</span> <span className="vote-meta__count" style={{ color }}>
<span className="vote-meta__label">vote{voteCount !== 1 ? "s" : ""}</span> {voteCount}
</span>
<span className="vote-meta__label">
vote{voteCount !== 1 ? "s" : ""}
</span>
<span className="vote-meta__dots"> <span className="vote-meta__dots">
{voters.map((v, i) => { {voters.map((v, i) => {
const logo = getLogo(v.voter.name); const logo = getLogo(v.voter.name);
return logo ? ( return logo ? (
<img key={i} src={logo} alt={v.voter.name} title={v.voter.name} className="voter-dot" /> <img
key={i}
src={logo}
alt={v.voter.name}
title={v.voter.name}
className="voter-dot"
/>
) : ( ) : (
<span key={i} className="voter-dot voter-dot--letter" style={{ color: getColor(v.voter.name) }} title={v.voter.name}> <span
key={i}
className="voter-dot voter-dot--letter"
style={{ color: getColor(v.voter.name) }}
title={v.voter.name}
>
{v.voter.name[0]} {v.voter.name[0]}
</span> </span>
); );
@@ -168,26 +232,31 @@ function Arena({ round, total }: { round: RoundState; total: number | null }) {
const showVotes = round.phase === "voting" || round.phase === "done"; const showVotes = round.phase === "voting" || round.phase === "done";
const isDone = round.phase === "done"; const isDone = round.phase === "done";
let votesA = 0, votesB = 0; let votesA = 0,
votesB = 0;
for (const v of round.votes) { for (const v of round.votes) {
if (v.votedFor?.name === contA.name) votesA++; if (v.votedFor?.name === contA.name) votesA++;
else if (v.votedFor?.name === contB.name) votesB++; else if (v.votedFor?.name === contB.name) votesB++;
} }
const totalVotes = votesA + votesB; const totalVotes = votesA + votesB;
const votersA = round.votes.filter(v => v.votedFor?.name === contA.name); const votersA = round.votes.filter((v) => v.votedFor?.name === contA.name);
const votersB = round.votes.filter(v => v.votedFor?.name === contB.name); const votersB = round.votes.filter((v) => v.votedFor?.name === contB.name);
const phaseText = const phaseText =
round.phase === "prompting" ? "Writing prompt" : round.phase === "prompting"
round.phase === "answering" ? "Answering" : ? "Writing prompt"
round.phase === "voting" ? "Judges voting" : : round.phase === "answering"
"Complete"; ? "Answering"
: round.phase === "voting"
? "Judges voting"
: "Complete";
return ( return (
<div className="arena"> <div className="arena">
<div className="arena__meta"> <div className="arena__meta">
<span className="arena__round"> <span className="arena__round">
Round {round.num}{total ? <span className="dim">/{total}</span> : null} Round {round.num}
{total ? <span className="dim">/{total}</span> : null}
</span> </span>
<span className="arena__phase">{phaseText}</span> <span className="arena__phase">{phaseText}</span>
</div> </div>
@@ -234,7 +303,10 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
{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>
<span className="game-over__name" style={{ color: getColor(champion[0]) }}> <span
className="game-over__name"
style={{ color: getColor(champion[0]) }}
>
{getLogo(champion[0]) && <img src={getLogo(champion[0])!} alt="" />} {getLogo(champion[0]) && <img src={getLogo(champion[0])!} alt="" />}
{champion[0]} {champion[0]}
</span> </span>
@@ -247,19 +319,30 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
// ── Standings ──────────────────────────────────────────────────────────────── // ── Standings ────────────────────────────────────────────────────────────────
function Standings({ scores, activeRound }: { scores: Record<string, number>; activeRound: RoundState | null }) { function Standings({
scores,
activeRound,
}: {
scores: Record<string, number>;
activeRound: RoundState | null;
}) {
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;
const competing = activeRound const competing = activeRound
? new Set([activeRound.contestants[0].name, activeRound.contestants[1].name]) ? new Set([
activeRound.contestants[0].name,
activeRound.contestants[1].name,
])
: new Set<string>(); : new Set<string>();
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">Standings</span>
<a href="/history" className="standings__link">History </a> <a href="/history" className="standings__link">
History
</a>
</div> </div>
<div className="standings__list"> <div className="standings__list">
{sorted.map(([name, score], i) => { {sorted.map(([name, score], i) => {
@@ -267,11 +350,19 @@ function Standings({ scores, activeRound }: { scores: Record<string, number>; ac
const color = getColor(name); const color = getColor(name);
const active = competing.has(name); const active = competing.has(name);
return ( return (
<div key={name} className={`standing ${active ? "standing--active" : ""}`}> <div
<span className="standing__rank">{i === 0 && score > 0 ? "👑" : i + 1}</span> key={name}
className={`standing ${active ? "standing--active" : ""}`}
>
<span className="standing__rank">
{i === 0 && score > 0 ? "👑" : i + 1}
</span>
<ModelTag model={{ id: name, name }} small /> <ModelTag model={{ id: name, name }} small />
<div className="standing__bar"> <div className="standing__bar">
<div className="standing__fill" style={{ width: `${pct}%`, background: color }} /> <div
className="standing__fill"
style={{ width: `${pct}%`, background: color }}
/>
</div> </div>
<span className="standing__score">{score}</span> <span className="standing__score">{score}</span>
</div> </div>
@@ -287,8 +378,13 @@ function Standings({ scores, activeRound }: { scores: Record<string, number>; ac
function ConnectingScreen() { function ConnectingScreen() {
return ( return (
<div className="connecting"> <div className="connecting">
<div className="connecting__logo"><img src="/assets/logo.svg" alt="Qwipslop" /></div> <div className="connecting__logo">
<div className="connecting__sub">Connecting<Dots /></div> <img src="/assets/logo.svg" alt="quipslop" />
</div>
<div className="connecting__sub">
Connecting
<Dots />
</div>
</div> </div>
); );
} }
@@ -334,8 +430,10 @@ function App() {
if (!connected || !state) return <ConnectingScreen />; if (!connected || !state) return <ConnectingScreen />;
const lastCompleted = state.completed[state.completed.length - 1]; const lastCompleted = state.completed[state.completed.length - 1];
const isNextPrompting = state.active?.phase === "prompting" && !state.active.prompt; const isNextPrompting =
const displayRound = isNextPrompting && lastCompleted ? lastCompleted : state.active; state.active?.phase === "prompting" && !state.active.prompt;
const displayRound =
isNextPrompting && lastCompleted ? lastCompleted : state.active;
return ( return (
<div className="app"> <div className="app">
@@ -343,7 +441,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="Qwipslop" /> <img src="/assets/logo.svg" alt="quipslop" />
</a> </a>
<div className="viewer-pill" aria-live="polite"> <div className="viewer-pill" aria-live="polite">
<span className="viewer-pill__dot" /> <span className="viewer-pill__dot" />
@@ -356,12 +454,17 @@ function App() {
) : displayRound ? ( ) : displayRound ? (
<Arena round={displayRound} total={totalRounds} /> <Arena round={displayRound} total={totalRounds} />
) : ( ) : (
<div className="waiting">Starting<Dots /></div> <div className="waiting">
Starting
<Dots />
</div>
)} )}
{isNextPrompting && lastCompleted && ( {isNextPrompting && lastCompleted && (
<div className="next-toast"> <div className="next-toast">
<ModelTag model={state.active!.prompter} small /> is writing the next prompt<Dots /> <ModelTag model={state.active!.prompter} small /> is writing the
next prompt
<Dots />
</div> </div>
)} )}
</main> </main>

View File

@@ -1,16 +1,19 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<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>Qwipslop History</title> <title>quipslop History</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 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
<link rel="stylesheet" href="./history.css" /> 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"
</head> rel="stylesheet"
<body> />
<div id="root"></div> <link rel="stylesheet" href="./history.css" />
<script type="module" src="./history.tsx"></script> </head>
</body> <body>
</html> <div id="root"></div>
<script type="module" src="./history.tsx"></script>
</body>
</html>

View File

@@ -5,8 +5,20 @@ import "./history.css";
// ── Types ─────────────────────────────────────────────────────────────────── // ── Types ───────────────────────────────────────────────────────────────────
type Model = { id: string; name: string }; type Model = { id: string; name: string };
type TaskInfo = { model: Model; startedAt: number; finishedAt?: number; result?: string; error?: string }; type TaskInfo = {
type VoteInfo = { voter: Model; startedAt: number; finishedAt?: number; votedFor?: Model; error?: boolean }; model: Model;
startedAt: number;
finishedAt?: number;
result?: string;
error?: string;
};
type VoteInfo = {
voter: Model;
startedAt: number;
finishedAt?: number;
votedFor?: Model;
error?: boolean;
};
type RoundState = { type RoundState = {
num: number; num: number;
phase: "prompting" | "answering" | "voting" | "done"; phase: "prompting" | "answering" | "voting" | "done";
@@ -44,13 +56,20 @@ function getLogo(name: string): string | null {
if (name.includes("DeepSeek")) return "/assets/logos/deepseek.svg"; if (name.includes("DeepSeek")) return "/assets/logos/deepseek.svg";
if (name.includes("GLM")) return "/assets/logos/glm.svg"; if (name.includes("GLM")) return "/assets/logos/glm.svg";
if (name.includes("GPT")) return "/assets/logos/openai.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("Opus") || name.includes("Sonnet"))
return "/assets/logos/claude.svg";
if (name.includes("Grok")) return "/assets/logos/grok.svg"; if (name.includes("Grok")) return "/assets/logos/grok.svg";
if (name.includes("MiniMax")) return "/assets/logos/minimax.svg"; if (name.includes("MiniMax")) return "/assets/logos/minimax.svg";
return null; return null;
} }
function ModelName({ model, className = "" }: { model: Model; className?: string }) { function ModelName({
model,
className = "",
}: {
model: Model;
className?: string;
}) {
const logo = getLogo(model.name); const logo = getLogo(model.name);
const color = getColor(model.name); const color = getColor(model.name);
return ( return (
@@ -63,12 +82,12 @@ function ModelName({ model, className = "" }: { model: Model; className?: string
// ── Components ────────────────────────────────────────────────────────────── // ── Components ──────────────────────────────────────────────────────────────
function HistoryContestant({ function HistoryContestant({
task, task,
votes, votes,
voters voters,
}: { }: {
task: TaskInfo; task: TaskInfo;
votes: number; votes: number;
voters: Model[]; voters: Model[];
}) { }) {
@@ -83,13 +102,21 @@ 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 ? "vote" : "votes"}
</div> </div>
<div className="history-contestant__voters"> <div className="history-contestant__voters">
{voters.map(v => { {voters.map((v) => {
const logo = getLogo(v.name); const logo = getLogo(v.name);
if (!logo) return null; if (!logo) return null;
return <img key={v.name} src={logo} title={v.name} alt={v.name} className="voter-mini-logo" />; return (
<img
key={v.name}
src={logo}
title={v.name}
alt={v.name}
className="voter-mini-logo"
/>
);
})} })}
</div> </div>
</div> </div>
@@ -99,14 +126,20 @@ function HistoryContestant({
function HistoryCard({ round }: { round: RoundState }) { function HistoryCard({ round }: { round: RoundState }) {
const [contA, contB] = round.contestants; const [contA, contB] = round.contestants;
let votesA = 0, votesB = 0; let votesA = 0,
votesB = 0;
const votersA: Model[] = []; const votersA: Model[] = [];
const votersB: Model[] = []; const votersB: Model[] = [];
for (const v of round.votes) { for (const v of round.votes) {
if (v.votedFor?.name === contA.name) { votesA++; votersA.push(v.voter); } if (v.votedFor?.name === contA.name) {
else if (v.votedFor?.name === contB.name) { votesB++; votersB.push(v.voter); } votesA++;
votersA.push(v.voter);
} else if (v.votedFor?.name === contB.name) {
votesB++;
votersB.push(v.voter);
}
} }
const isAWinner = votesA > votesB; const isAWinner = votesA > votesB;
@@ -119,44 +152,80 @@ function HistoryCard({ round }: { round: RoundState }) {
<div className="history-card__prompter"> <div className="history-card__prompter">
Prompted by <ModelName model={round.prompter} /> Prompted by <ModelName model={round.prompter} />
</div> </div>
<div className="history-card__prompt"> <div className="history-card__prompt">{round.prompt}</div>
{round.prompt}
</div>
</div> </div>
<div className="history-card__meta"> <div className="history-card__meta">
<div>R{round.num}</div> <div>R{round.num}</div>
</div> </div>
</div> </div>
<div className="history-card__showdown"> <div className="history-card__showdown">
<div className={`history-contestant ${isAWinner ? "history-contestant--winner" : ""}`}> <div
className={`history-contestant ${isAWinner ? "history-contestant--winner" : ""}`}
>
<div className="history-contestant__header"> <div className="history-contestant__header">
<ModelName model={contA} /> <ModelName model={contA} />
{isAWinner && <div className="history-contestant__winner-badge">WINNER</div>} {isAWinner && (
<div className="history-contestant__winner-badge">WINNER</div>
)}
</div>
<div className="history-contestant__answer">
&ldquo;{round.answerTasks[0].result}&rdquo;
</div> </div>
<div className="history-contestant__answer">&ldquo;{round.answerTasks[0].result}&rdquo;</div>
<div className="history-contestant__votes"> <div className="history-contestant__votes">
<div className="history-contestant__score" style={{ color: getColor(contA.name) }}> <div
{votesA} {votesA === 1 ? 'vote' : 'votes'} className="history-contestant__score"
style={{ color: getColor(contA.name) }}
>
{votesA} {votesA === 1 ? "vote" : "votes"}
</div> </div>
<div className="history-contestant__voters"> <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" />)} {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>
</div> </div>
<div className={`history-contestant ${isBWinner ? "history-contestant--winner" : ""}`}> <div
className={`history-contestant ${isBWinner ? "history-contestant--winner" : ""}`}
>
<div className="history-contestant__header"> <div className="history-contestant__header">
<ModelName model={contB} /> <ModelName model={contB} />
{isBWinner && <div className="history-contestant__winner-badge">WINNER</div>} {isBWinner && (
<div className="history-contestant__winner-badge">WINNER</div>
)}
</div>
<div className="history-contestant__answer">
&ldquo;{round.answerTasks[1].result}&rdquo;
</div> </div>
<div className="history-contestant__answer">&ldquo;{round.answerTasks[1].result}&rdquo;</div>
<div className="history-contestant__votes"> <div className="history-contestant__votes">
<div className="history-contestant__score" style={{ color: getColor(contB.name) }}> <div
{votesB} {votesB === 1 ? 'vote' : 'votes'} className="history-contestant__score"
style={{ color: getColor(contB.name) }}
>
{votesB} {votesB === 1 ? "vote" : "votes"}
</div> </div>
<div className="history-contestant__voters"> <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" />)} {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> </div>
@@ -177,13 +246,13 @@ function App() {
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
fetch(`/api/history?page=${page}`) fetch(`/api/history?page=${page}`)
.then(res => res.json()) .then((res) => res.json())
.then(data => { .then((data) => {
setRounds(data.rounds); setRounds(data.rounds);
setTotalPages(data.totalPages || 1); setTotalPages(data.totalPages || 1);
setLoading(false); setLoading(false);
}) })
.catch(err => { .catch((err) => {
setError(err.message); setError(err.message);
setLoading(false); setLoading(false);
}); });
@@ -191,11 +260,15 @@ function App() {
return ( return (
<div className="app"> <div className="app">
<a href="/" className="main-logo">QWIPSLOP</a> <a href="/" className="main-logo">
quipslop
</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">Past Rounds</div>
<a href="/" className="back-link"> Back to Game</a> <a href="/" className="back-link">
Back to Game
</a>
</div> </div>
{loading ? ( {loading ? (
@@ -206,26 +279,31 @@ function App() {
<div className="empty">No past rounds found.</div> <div className="empty">No past rounds found.</div>
) : ( ) : (
<> <>
<div className="history-list" style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}> <div
{rounds.map(r => ( className="history-list"
style={{ display: "flex", flexDirection: "column", gap: "32px" }}
>
{rounds.map((r) => (
<HistoryCard key={r.num + "-" + Math.random()} round={r} /> <HistoryCard key={r.num + "-" + Math.random()} round={r} />
))} ))}
</div> </div>
{totalPages > 1 && ( {totalPages > 1 && (
<div className="pagination"> <div className="pagination">
<button <button
className="pagination__btn" className="pagination__btn"
disabled={page === 1} disabled={page === 1}
onClick={() => setPage(p => p - 1)} onClick={() => setPage((p) => p - 1)}
> >
PREV PREV
</button> </button>
<span className="pagination__info">Page {page} of {totalPages}</span> <span className="pagination__info">
<button Page {page} of {totalPages}
className="pagination__btn" </span>
disabled={page === totalPages} <button
onClick={() => setPage(p => p + 1)} className="pagination__btn"
disabled={page === totalPages}
onClick={() => setPage((p) => p + 1)}
> >
NEXT NEXT
</button> </button>
@@ -241,4 +319,4 @@ function App() {
// ── Mount ─────────────────────────────────────────────────────────────────── // ── Mount ───────────────────────────────────────────────────────────────────
const root = createRoot(document.getElementById("root")!); const root = createRoot(document.getElementById("root")!);
root.render(<App />); root.render(<App />);

View File

@@ -1,17 +1,20 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<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>Qwipslop</title> <title>quipslop</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 />
<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
<link rel="stylesheet" href="./frontend.css" /> 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"
</head> rel="stylesheet"
<body> />
<div id="root"></div> <link rel="stylesheet" href="./frontend.css" />
<script type="module" src="./frontend.tsx"></script> </head>
</body> <body>
<div id="root"></div>
<script type="module" src="./frontend.tsx"></script>
</body>
</html> </html>

View File

@@ -50,7 +50,7 @@ function RoundView({ round, total }: { round: RoundState; total: number }) {
{/* Header */} {/* Header */}
<Box> <Box>
<Text bold backgroundColor="blueBright" color="black"> <Text bold backgroundColor="blueBright" color="black">
{` ROUND ${round.num}${total !== Infinity && total !== null ? `/${total}` : ''} `} {` ROUND ${round.num}${total !== Infinity && total !== null ? `/${total}` : ""} `}
</Text> </Text>
</Box> </Box>
<Text dimColor>{"─".repeat(50)}</Text> <Text dimColor>{"─".repeat(50)}</Text>
@@ -196,7 +196,9 @@ function Scoreboard({ scores }: { scores: Record<string, number> }) {
{name.padEnd(NAME_PAD)} {name.padEnd(NAME_PAD)}
</Text> </Text>
<Text color={MODEL_COLORS[name]}>{bar}</Text> <Text color={MODEL_COLORS[name]}>{bar}</Text>
<Text bold>{score} {score === 1 ? 'win' : 'wins'}</Text> <Text bold>
{score} {score === 1 ? "win" : "wins"}
</Text>
<Text>{medal}</Text> <Text>{medal}</Text>
</Box> </Box>
); );
@@ -260,7 +262,8 @@ function Game({ runs }: { runs: number }) {
const runsArg = process.argv.find((a) => a.startsWith("runs=")); const runsArg = process.argv.find((a) => a.startsWith("runs="));
const runsVal = runsArg ? runsArg.split("=")[1] : "infinite"; const runsVal = runsArg ? runsArg.split("=")[1] : "infinite";
const runs = runsVal === "infinite" ? Infinity : parseInt(runsVal || "infinite", 10); const runs =
runsVal === "infinite" ? Infinity : parseInt(runsVal || "infinite", 10);
if (!process.env.OPENROUTER_API_KEY) { if (!process.env.OPENROUTER_API_KEY) {
console.error("Error: Set OPENROUTER_API_KEY environment variable"); console.error("Error: Set OPENROUTER_API_KEY environment variable");
@@ -272,10 +275,8 @@ log("INFO", "startup", `Game starting: ${runs} rounds`, {
}); });
console.log( console.log(
`\n\x1b[1m\x1b[45m\x1b[30m QWIPSLOP \x1b[0m \x1b[2m${runs} rounds\x1b[0m`, `\n\x1b[1m\x1b[45m\x1b[30m quipslop \x1b[0m \x1b[2m${runs} rounds\x1b[0m`,
);
console.log(
`\x1b[2mModels: ${MODELS.map((m) => m.name).join(", ")}\x1b[0m\n`,
); );
console.log(`\x1b[2mModels: ${MODELS.map((m) => m.name).join(", ")}\x1b[0m\n`);
render(<Game runs={runs} />); render(<Game runs={runs} />);

View File

@@ -16,7 +16,8 @@ import {
const runsArg = process.argv.find((a) => a.startsWith("runs=")); const runsArg = process.argv.find((a) => a.startsWith("runs="));
const runsStr = runsArg ? runsArg.split("=")[1] : "infinite"; const runsStr = runsArg ? runsArg.split("=")[1] : "infinite";
const runs = runsStr === "infinite" ? Infinity : parseInt(runsStr || "infinite", 10); const runs =
runsStr === "infinite" ? Infinity : parseInt(runsStr || "infinite", 10);
if (!process.env.OPENROUTER_API_KEY) { if (!process.env.OPENROUTER_API_KEY) {
console.error("Error: Set OPENROUTER_API_KEY environment variable"); console.error("Error: Set OPENROUTER_API_KEY environment variable");
@@ -31,9 +32,11 @@ if (allRounds.length > 0) {
for (const round of allRounds) { for (const round of allRounds) {
if (round.scoreA !== undefined && round.scoreB !== undefined) { if (round.scoreA !== undefined && round.scoreB !== undefined) {
if (round.scoreA > round.scoreB) { if (round.scoreA > round.scoreB) {
initialScores[round.contestants[0].name] = (initialScores[round.contestants[0].name] || 0) + 1; initialScores[round.contestants[0].name] =
(initialScores[round.contestants[0].name] || 0) + 1;
} else if (round.scoreB > round.scoreA) { } else if (round.scoreB > round.scoreA) {
initialScores[round.contestants[1].name] = (initialScores[round.contestants[1].name] || 0) + 1; initialScores[round.contestants[1].name] =
(initialScores[round.contestants[1].name] || 0) + 1;
} }
} }
} }
@@ -56,15 +59,33 @@ const gameState: GameState = {
type WsData = { ip: string }; type WsData = { ip: string };
const WINDOW_MS = 60_000; const WINDOW_MS = 60_000;
const WS_UPGRADE_LIMIT_PER_MIN = parsePositiveInt(process.env.WS_UPGRADE_LIMIT_PER_MIN, 20); const WS_UPGRADE_LIMIT_PER_MIN = parsePositiveInt(
const HISTORY_LIMIT_PER_MIN = parsePositiveInt(process.env.HISTORY_LIMIT_PER_MIN, 120); process.env.WS_UPGRADE_LIMIT_PER_MIN,
const ADMIN_LIMIT_PER_MIN = parsePositiveInt(process.env.ADMIN_LIMIT_PER_MIN, 10); 20,
);
const HISTORY_LIMIT_PER_MIN = parsePositiveInt(
process.env.HISTORY_LIMIT_PER_MIN,
120,
);
const ADMIN_LIMIT_PER_MIN = parsePositiveInt(
process.env.ADMIN_LIMIT_PER_MIN,
10,
);
const MAX_WS_GLOBAL = parsePositiveInt(process.env.MAX_WS_GLOBAL, 2_000); const MAX_WS_GLOBAL = parsePositiveInt(process.env.MAX_WS_GLOBAL, 2_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_HISTORY_PAGE = parsePositiveInt(process.env.MAX_HISTORY_PAGE, 100_000); const MAX_HISTORY_PAGE = parsePositiveInt(
process.env.MAX_HISTORY_PAGE,
100_000,
);
const MAX_HISTORY_LIMIT = parsePositiveInt(process.env.MAX_HISTORY_LIMIT, 50); const MAX_HISTORY_LIMIT = parsePositiveInt(process.env.MAX_HISTORY_LIMIT, 50);
const HISTORY_CACHE_TTL_MS = parsePositiveInt(process.env.HISTORY_CACHE_TTL_MS, 5_000); const HISTORY_CACHE_TTL_MS = parsePositiveInt(
const MAX_HISTORY_CACHE_KEYS = parsePositiveInt(process.env.MAX_HISTORY_CACHE_KEYS, 500); process.env.HISTORY_CACHE_TTL_MS,
5_000,
);
const MAX_HISTORY_CACHE_KEYS = parsePositiveInt(
process.env.MAX_HISTORY_CACHE_KEYS,
500,
);
const requestWindows = new Map<string, number[]>(); const requestWindows = new Map<string, number[]>();
const wsByIp = new Map<string, number>(); const wsByIp = new Map<string, number>();
@@ -85,7 +106,9 @@ function isRateLimited(key: string, limit: number, windowMs: number): boolean {
const now = Date.now(); const now = Date.now();
if (now - lastRateWindowSweep >= windowMs) { if (now - lastRateWindowSweep >= windowMs) {
for (const [bucketKey, timestamps] of requestWindows) { for (const [bucketKey, timestamps] of requestWindows) {
const recent = timestamps.filter((timestamp) => now - timestamp <= windowMs); const recent = timestamps.filter(
(timestamp) => now - timestamp <= windowMs,
);
if (recent.length === 0) { if (recent.length === 0) {
requestWindows.delete(bucketKey); requestWindows.delete(bucketKey);
} else { } else {
@@ -116,7 +139,8 @@ function secureCompare(a: string, b: string): boolean {
function isAdminAuthorized(req: Request, url: URL): boolean { function isAdminAuthorized(req: Request, url: URL): boolean {
const expected = process.env.ADMIN_SECRET; const expected = process.env.ADMIN_SECRET;
if (!expected) return false; if (!expected) return false;
const provided = req.headers.get("x-admin-secret") ?? url.searchParams.get("secret") ?? ""; const provided =
req.headers.get("x-admin-secret") ?? url.searchParams.get("secret") ?? "";
if (!provided) return false; if (!provided) return false;
return secureCompare(provided, expected); return secureCompare(provided, expected);
} }
@@ -203,9 +227,12 @@ const server = Bun.serve<WsData>({
gameState.isPaused = false; gameState.isPaused = false;
} }
broadcast(); broadcast();
return new Response(url.pathname === "/api/pause" ? "Paused" : "Resumed", { return new Response(
status: 200, url.pathname === "/api/pause" ? "Paused" : "Resumed",
}); {
status: 200,
},
);
} }
if (url.pathname === "/api/history") { if (url.pathname === "/api/history") {
@@ -214,8 +241,12 @@ const server = Bun.serve<WsData>({
} }
const rawPage = parseInt(url.searchParams.get("page") || "1", 10); const rawPage = parseInt(url.searchParams.get("page") || "1", 10);
const rawLimit = parseInt(url.searchParams.get("limit") || "10", 10); const rawLimit = parseInt(url.searchParams.get("limit") || "10", 10);
const page = Number.isFinite(rawPage) ? Math.min(Math.max(rawPage, 1), MAX_HISTORY_PAGE) : 1; const page = Number.isFinite(rawPage)
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), MAX_HISTORY_LIMIT) : 10; ? Math.min(Math.max(rawPage, 1), MAX_HISTORY_PAGE)
: 1;
const limit = Number.isFinite(rawLimit)
? Math.min(Math.max(rawLimit, 1), MAX_HISTORY_LIMIT)
: 10;
const cacheKey = `${page}:${limit}`; const cacheKey = `${page}:${limit}`;
const now = Date.now(); const now = Date.now();
if (now - lastHistoryCacheSweep >= HISTORY_CACHE_TTL_MS) { if (now - lastHistoryCacheSweep >= HISTORY_CACHE_TTL_MS) {
@@ -253,7 +284,9 @@ const server = Bun.serve<WsData>({
headers: { Allow: "GET" }, headers: { Allow: "GET" },
}); });
} }
if (isRateLimited(`ws-upgrade:${ip}`, WS_UPGRADE_LIMIT_PER_MIN, WINDOW_MS)) { if (
isRateLimited(`ws-upgrade:${ip}`, WS_UPGRADE_LIMIT_PER_MIN, WINDOW_MS)
) {
return new Response("Too Many Requests", { status: 429 }); return new Response("Too Many Requests", { status: 429 });
} }
if (clients.size >= MAX_WS_GLOBAL) { if (clients.size >= MAX_WS_GLOBAL) {
@@ -289,10 +322,13 @@ const server = Bun.serve<WsData>({
broadcast(); broadcast();
}, },
}, },
development: process.env.NODE_ENV === "production" ? false : { development:
hmr: true, process.env.NODE_ENV === "production"
console: true, ? false
}, : {
hmr: true,
console: true,
},
error(error) { error(error) {
log("ERROR", "server", "Unhandled fetch/websocket error", { log("ERROR", "server", "Unhandled fetch/websocket error", {
message: error.message, message: error.message,
@@ -302,7 +338,7 @@ const server = Bun.serve<WsData>({
}, },
}); });
console.log(`\n🎮 Qwipslop Web — http://localhost:${server.port}`); console.log(`\n🎮 quipslop 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} rounds with ${MODELS.length} models\n`);