rename
This commit is contained in:
183
frontend.tsx
183
frontend.tsx
@@ -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>
|
||||||
|
|||||||
31
history.html
31
history.html
@@ -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>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./history.tsx"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
150
history.tsx
150
history.tsx
@@ -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 (
|
||||||
@@ -66,7 +85,7 @@ function ModelName({ model, className = "" }: { model: Model; className?: string
|
|||||||
function HistoryContestant({
|
function HistoryContestant({
|
||||||
task,
|
task,
|
||||||
votes,
|
votes,
|
||||||
voters
|
voters,
|
||||||
}: {
|
}: {
|
||||||
task: TaskInfo;
|
task: TaskInfo;
|
||||||
votes: number;
|
votes: number;
|
||||||
@@ -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>
|
||||||
@@ -100,13 +127,19 @@ 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,9 +152,7 @@ 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>
|
||||||
@@ -129,34 +160,72 @@ function HistoryCard({ round }: { round: RoundState }) {
|
|||||||
</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">
|
||||||
|
“{round.answerTasks[0].result}”
|
||||||
</div>
|
</div>
|
||||||
<div className="history-contestant__answer">“{round.answerTasks[0].result}”</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">
|
||||||
|
“{round.answerTasks[1].result}”
|
||||||
</div>
|
</div>
|
||||||
<div className="history-contestant__answer">“{round.answerTasks[1].result}”</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,8 +279,11 @@ 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>
|
||||||
@@ -217,15 +293,17 @@ function App() {
|
|||||||
<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">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
className="pagination__btn"
|
className="pagination__btn"
|
||||||
disabled={page === totalPages}
|
disabled={page === totalPages}
|
||||||
onClick={() => setPage(p => p + 1)}
|
onClick={() => setPage((p) => p + 1)}
|
||||||
>
|
>
|
||||||
NEXT
|
NEXT
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
33
index.html
33
index.html
@@ -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>
|
||||||
|
|||||||
15
quipslop.tsx
15
quipslop.tsx
@@ -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} />);
|
||||||
|
|||||||
80
server.ts
80
server.ts
@@ -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`);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user