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 ────────────────────────────────────────────────────────────────────
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 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";
@@ -19,8 +31,18 @@ type RoundState = {
scoreA?: number;
scoreB?: number;
};
type GameState = { completed: RoundState[]; active: RoundState | null; scores: Record<string, number>; done: boolean };
type ServerMessage = { type: "state"; data: GameState; totalRounds: number; viewerCount: number };
type GameState = {
completed: RoundState[];
active: RoundState | null;
scores: Record<string, number>;
done: boolean;
};
type ServerMessage = {
type: "state";
data: GameState;
totalRounds: number;
viewerCount: number;
};
// ── 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("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("Opus") || name.includes("Sonnet"))
return "/assets/logos/claude.svg";
if (name.includes("Grok")) return "/assets/logos/grok.svg";
if (name.includes("MiniMax")) return "/assets/logos/minimax.svg";
return null;
@@ -55,14 +78,23 @@ function getLogo(name: string): string | null {
// ── Helpers ──────────────────────────────────────────────────────────────────
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 }) {
const logo = getLogo(model.name);
const color = getColor(model.name);
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" />}
{model.name}
</span>
@@ -76,9 +108,12 @@ function PromptCard({ round }: { round: RoundState }) {
return (
<div className="prompt">
<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 className="prompt__text prompt__text--loading"><Dots /></div>
</div>
);
}
@@ -86,14 +121,18 @@ function PromptCard({ round }: { round: RoundState }) {
if (round.promptTask.error) {
return (
<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>
);
}
return (
<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>
);
@@ -102,7 +141,12 @@ function PromptCard({ round }: { round: RoundState }) {
// ── Contestant ───────────────────────────────────────────────────────────────
function ContestantCard({
task, voteCount, totalVotes, isWinner, showVotes, voters,
task,
voteCount,
totalVotes,
isWinner,
showVotes,
voters,
}: {
task: TaskInfo;
voteCount: number;
@@ -126,7 +170,9 @@ function ContestantCard({
<div className="contestant__body">
{!task.finishedAt ? (
<p className="answer answer--loading"><Dots /></p>
<p className="answer answer--loading">
<Dots />
</p>
) : task.error ? (
<p className="answer answer--error">{task.error}</p>
) : (
@@ -137,18 +183,36 @@ function ContestantCard({
{showVotes && (
<div className="contestant__foot">
<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 className="vote-meta">
<span className="vote-meta__count" style={{ color }}>{voteCount}</span>
<span className="vote-meta__label">vote{voteCount !== 1 ? "s" : ""}</span>
<span className="vote-meta__count" style={{ color }}>
{voteCount}
</span>
<span className="vote-meta__label">
vote{voteCount !== 1 ? "s" : ""}
</span>
<span className="vote-meta__dots">
{voters.map((v, i) => {
const logo = getLogo(v.voter.name);
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]}
</span>
);
@@ -168,26 +232,31 @@ function Arena({ round, total }: { round: RoundState; total: number | null }) {
const showVotes = round.phase === "voting" || round.phase === "done";
const isDone = round.phase === "done";
let votesA = 0, votesB = 0;
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 totalVotes = votesA + votesB;
const votersA = round.votes.filter(v => v.votedFor?.name === contA.name);
const votersB = round.votes.filter(v => v.votedFor?.name === contB.name);
const votersA = round.votes.filter((v) => v.votedFor?.name === contA.name);
const votersB = round.votes.filter((v) => v.votedFor?.name === contB.name);
const phaseText =
round.phase === "prompting" ? "Writing prompt" :
round.phase === "answering" ? "Answering" :
round.phase === "voting" ? "Judges voting" :
"Complete";
round.phase === "prompting"
? "Writing prompt"
: round.phase === "answering"
? "Answering"
: round.phase === "voting"
? "Judges voting"
: "Complete";
return (
<div className="arena">
<div className="arena__meta">
<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 className="arena__phase">{phaseText}</span>
</div>
@@ -234,7 +303,10 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
{champion && champion[1] > 0 && (
<div className="game-over__winner">
<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="" />}
{champion[0]}
</span>
@@ -247,19 +319,30 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
// ── 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 maxScore = sorted[0]?.[1] || 1;
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>();
return (
<aside className="standings">
<div className="standings__head">
<span className="standings__title">Standings</span>
<a href="/history" className="standings__link">History </a>
<a href="/history" className="standings__link">
History
</a>
</div>
<div className="standings__list">
{sorted.map(([name, score], i) => {
@@ -267,11 +350,19 @@ function Standings({ scores, activeRound }: { scores: Record<string, number>; ac
const color = getColor(name);
const active = competing.has(name);
return (
<div key={name} className={`standing ${active ? "standing--active" : ""}`}>
<span className="standing__rank">{i === 0 && score > 0 ? "👑" : i + 1}</span>
<div
key={name}
className={`standing ${active ? "standing--active" : ""}`}
>
<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
className="standing__fill"
style={{ width: `${pct}%`, background: color }}
/>
</div>
<span className="standing__score">{score}</span>
</div>
@@ -287,8 +378,13 @@ function Standings({ scores, activeRound }: { scores: Record<string, number>; ac
function ConnectingScreen() {
return (
<div className="connecting">
<div className="connecting__logo"><img src="/assets/logo.svg" alt="Qwipslop" /></div>
<div className="connecting__sub">Connecting<Dots /></div>
<div className="connecting__logo">
<img src="/assets/logo.svg" alt="quipslop" />
</div>
<div className="connecting__sub">
Connecting
<Dots />
</div>
</div>
);
}
@@ -334,8 +430,10 @@ function App() {
if (!connected || !state) return <ConnectingScreen />;
const lastCompleted = state.completed[state.completed.length - 1];
const isNextPrompting = state.active?.phase === "prompting" && !state.active.prompt;
const displayRound = isNextPrompting && lastCompleted ? lastCompleted : state.active;
const isNextPrompting =
state.active?.phase === "prompting" && !state.active.prompt;
const displayRound =
isNextPrompting && lastCompleted ? lastCompleted : state.active;
return (
<div className="app">
@@ -343,7 +441,7 @@ function App() {
<main className="main">
<header className="header">
<a href="/" className="logo">
<img src="/assets/logo.svg" alt="Qwipslop" />
<img src="/assets/logo.svg" alt="quipslop" />
</a>
<div className="viewer-pill" aria-live="polite">
<span className="viewer-pill__dot" />
@@ -356,12 +454,17 @@ function App() {
) : displayRound ? (
<Arena round={displayRound} total={totalRounds} />
) : (
<div className="waiting">Starting<Dots /></div>
<div className="waiting">
Starting
<Dots />
</div>
)}
{isNextPrompting && lastCompleted && (
<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>
)}
</main>

View File

@@ -1,16 +1,19 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Qwipslop 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>
<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>

View File

@@ -5,8 +5,20 @@ 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 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";
@@ -44,13 +56,20 @@ function getLogo(name: string): string | null {
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("Opus") || name.includes("Sonnet"))
return "/assets/logos/claude.svg";
if (name.includes("Grok")) return "/assets/logos/grok.svg";
if (name.includes("MiniMax")) return "/assets/logos/minimax.svg";
return null;
}
function ModelName({ model, className = "" }: { model: Model; className?: string }) {
function ModelName({
model,
className = "",
}: {
model: Model;
className?: string;
}) {
const logo = getLogo(model.name);
const color = getColor(model.name);
return (
@@ -63,12 +82,12 @@ function ModelName({ model, className = "" }: { model: Model; className?: string
// ── Components ──────────────────────────────────────────────────────────────
function HistoryContestant({
task,
votes,
voters
}: {
task: TaskInfo;
function HistoryContestant({
task,
votes,
voters,
}: {
task: TaskInfo;
votes: number;
voters: Model[];
}) {
@@ -83,13 +102,21 @@ function HistoryContestant({
</div>
<div className="history-contestant__votes">
<div className="history-contestant__score" style={{ color }}>
{votes} {votes === 1 ? 'vote' : 'votes'}
{votes} {votes === 1 ? "vote" : "votes"}
</div>
<div className="history-contestant__voters">
{voters.map(v => {
{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" />;
return (
<img
key={v.name}
src={logo}
title={v.name}
alt={v.name}
className="voter-mini-logo"
/>
);
})}
</div>
</div>
@@ -99,14 +126,20 @@ function HistoryContestant({
function HistoryCard({ round }: { round: RoundState }) {
const [contA, contB] = round.contestants;
let votesA = 0, votesB = 0;
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); }
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;
@@ -119,44 +152,80 @@ function HistoryCard({ round }: { round: RoundState }) {
<div className="history-card__prompter">
Prompted by <ModelName model={round.prompter} />
</div>
<div className="history-card__prompt">
{round.prompt}
</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 ${isAWinner ? "history-contestant--winner" : ""}`}
>
<div className="history-contestant__header">
<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 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
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" />)}
{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 ${isBWinner ? "history-contestant--winner" : ""}`}
>
<div className="history-contestant__header">
<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 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
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" />)}
{votersB.map(
(v) =>
getLogo(v.name) && (
<img
key={v.name}
src={getLogo(v.name)!}
title={v.name}
className="voter-mini-logo"
/>
),
)}
</div>
</div>
</div>
@@ -177,13 +246,13 @@ function App() {
useEffect(() => {
setLoading(true);
fetch(`/api/history?page=${page}`)
.then(res => res.json())
.then(data => {
.then((res) => res.json())
.then((data) => {
setRounds(data.rounds);
setTotalPages(data.totalPages || 1);
setLoading(false);
})
.catch(err => {
.catch((err) => {
setError(err.message);
setLoading(false);
});
@@ -191,11 +260,15 @@ function App() {
return (
<div className="app">
<a href="/" className="main-logo">QWIPSLOP</a>
<a href="/" className="main-logo">
quipslop
</a>
<main className="main">
<div className="page-header">
<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>
{loading ? (
@@ -206,26 +279,31 @@ function App() {
<div className="empty">No past rounds found.</div>
) : (
<>
<div className="history-list" style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
{rounds.map(r => (
<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)}
<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)}
<span className="pagination__info">
Page {page} of {totalPages}
</span>
<button
className="pagination__btn"
disabled={page === totalPages}
onClick={() => setPage((p) => p + 1)}
>
NEXT
</button>
@@ -241,4 +319,4 @@ function App() {
// ── Mount ───────────────────────────────────────────────────────────────────
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">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Qwipslop</title>
<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.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="./frontend.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./frontend.tsx"></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>quipslop</title>
<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.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="./frontend.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>

View File

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

View File

@@ -16,7 +16,8 @@ import {
const runsArg = process.argv.find((a) => a.startsWith("runs="));
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) {
console.error("Error: Set OPENROUTER_API_KEY environment variable");
@@ -31,9 +32,11 @@ if (allRounds.length > 0) {
for (const round of allRounds) {
if (round.scoreA !== undefined && round.scoreB !== undefined) {
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) {
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 };
const WINDOW_MS = 60_000;
const WS_UPGRADE_LIMIT_PER_MIN = parsePositiveInt(process.env.WS_UPGRADE_LIMIT_PER_MIN, 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 WS_UPGRADE_LIMIT_PER_MIN = parsePositiveInt(
process.env.WS_UPGRADE_LIMIT_PER_MIN,
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_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 HISTORY_CACHE_TTL_MS = parsePositiveInt(process.env.HISTORY_CACHE_TTL_MS, 5_000);
const MAX_HISTORY_CACHE_KEYS = parsePositiveInt(process.env.MAX_HISTORY_CACHE_KEYS, 500);
const HISTORY_CACHE_TTL_MS = parsePositiveInt(
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 wsByIp = new Map<string, number>();
@@ -85,7 +106,9 @@ function isRateLimited(key: string, limit: number, windowMs: number): boolean {
const now = Date.now();
if (now - lastRateWindowSweep >= windowMs) {
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) {
requestWindows.delete(bucketKey);
} else {
@@ -116,7 +139,8 @@ function secureCompare(a: string, b: string): boolean {
function isAdminAuthorized(req: Request, url: URL): boolean {
const expected = process.env.ADMIN_SECRET;
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;
return secureCompare(provided, expected);
}
@@ -203,9 +227,12 @@ const server = Bun.serve<WsData>({
gameState.isPaused = false;
}
broadcast();
return new Response(url.pathname === "/api/pause" ? "Paused" : "Resumed", {
status: 200,
});
return new Response(
url.pathname === "/api/pause" ? "Paused" : "Resumed",
{
status: 200,
},
);
}
if (url.pathname === "/api/history") {
@@ -214,8 +241,12 @@ const server = Bun.serve<WsData>({
}
const rawPage = parseInt(url.searchParams.get("page") || "1", 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 limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), MAX_HISTORY_LIMIT) : 10;
const page = Number.isFinite(rawPage)
? 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 now = Date.now();
if (now - lastHistoryCacheSweep >= HISTORY_CACHE_TTL_MS) {
@@ -253,7 +284,9 @@ const server = Bun.serve<WsData>({
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 });
}
if (clients.size >= MAX_WS_GLOBAL) {
@@ -289,10 +322,13 @@ const server = Bun.serve<WsData>({
broadcast();
},
},
development: process.env.NODE_ENV === "production" ? false : {
hmr: true,
console: true,
},
development:
process.env.NODE_ENV === "production"
? false
: {
hmr: true,
console: true,
},
error(error) {
log("ERROR", "server", "Unhandled fetch/websocket error", {
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(`🎯 ${runs} rounds with ${MODELS.length} models\n`);