Files
argument.es/quipslop.tsx
2026-02-22 18:44:49 -08:00

285 lines
9.1 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from "react";
import { render, Box, Text, Static, useApp } from "ink";
import {
MODELS,
MODEL_COLORS,
NAME_PAD,
LOG_FILE,
log,
runGame,
type Model,
type TaskInfo,
type VoteInfo,
type RoundState,
type GameState,
} from "./game.ts";
// ── Components ──────────────────────────────────────────────────────────────
function Timer({
startedAt,
finishedAt,
}: {
startedAt: number;
finishedAt?: number;
}) {
const [now, setNow] = useState(Date.now());
useEffect(() => {
if (finishedAt) return;
const id = setInterval(() => setNow(Date.now()), 100);
return () => clearInterval(id);
}, [finishedAt]);
const elapsed = ((finishedAt ?? now) - startedAt) / 1000;
return <Text dimColor>({elapsed.toFixed(1)}s)</Text>;
}
function MName({ model, pad }: { model: Model; pad?: boolean }) {
const name = pad ? model.name.padEnd(NAME_PAD) : model.name;
return (
<Text bold color={MODEL_COLORS[model.name]}>
{name}
</Text>
);
}
function RoundView({ round, total }: { round: RoundState; total: number }) {
const [contA, contB] = round.contestants;
return (
<Box flexDirection="column" marginBottom={1}>
{/* Header */}
<Box>
<Text bold backgroundColor="blueBright" color="black">
{` ROUND ${round.num}${total !== Infinity && total !== null ? `/${total}` : ""} `}
</Text>
</Box>
<Text dimColor>{"─".repeat(50)}</Text>
{/* Prompt */}
<Box marginTop={1} gap={1}>
<Text bold backgroundColor="magentaBright" color="black">
{" PROMPT "}
</Text>
<MName model={round.prompter} />
{!round.prompt && <Text dimColor>writing a prompt...</Text>}
<Timer
startedAt={round.promptTask.startedAt}
finishedAt={round.promptTask.finishedAt}
/>
</Box>
{round.promptTask.error && (
<Box marginLeft={2}>
<Text color="red"> {round.promptTask.error}</Text>
</Box>
)}
{round.prompt && (
<Box marginLeft={2} marginTop={1}>
<Text bold color="yellow">
"{round.prompt}"
</Text>
</Box>
)}
{/* Answers */}
{round.phase !== "prompting" && (
<Box flexDirection="column" marginTop={1}>
<Text bold backgroundColor="cyanBright" color="black">
{" ANSWERS "}
</Text>
{round.answerTasks.map((task, i) => (
<Box key={i} marginLeft={2} gap={1}>
<MName model={task.model} pad />
{!task.finishedAt ? (
<Text dimColor>thinking...</Text>
) : task.error ? (
<Text color="red"> {task.error}</Text>
) : (
<Text bold>"{task.result}"</Text>
)}
{task.startedAt > 0 && (
<Timer
startedAt={task.startedAt}
finishedAt={task.finishedAt}
/>
)}
</Box>
))}
</Box>
)}
{/* Votes */}
{(round.phase === "voting" || round.phase === "done") && (
<Box flexDirection="column" marginTop={1}>
<Text bold backgroundColor="yellowBright" color="black">
{" VOTES "}
</Text>
{round.votes.map((vote, i) => (
<Box key={i} marginLeft={2} gap={1}>
<MName model={vote.voter} pad />
{!vote.finishedAt ? (
<Text dimColor>voting...</Text>
) : vote.error || !vote.votedFor ? (
<Text color="red"> failed</Text>
) : (
<Text>
{"→ "}
<MName model={vote.votedFor} />
</Text>
)}
<Timer startedAt={vote.startedAt} finishedAt={vote.finishedAt} />
</Box>
))}
</Box>
)}
{/* Round result */}
{round.phase === "done" &&
round.scoreA !== undefined &&
round.scoreB !== undefined && (
<Box flexDirection="column" marginTop={1}>
<Text dimColor>{"─".repeat(50)}</Text>
<Box marginLeft={2} gap={1}>
{round.scoreA > round.scoreB ? (
<Text>
<MName model={contA} />{" "}
<Text bold>
wins! ({round.scoreA / 100} vs {round.scoreB / 100} votes)
</Text>
</Text>
) : round.scoreB > round.scoreA ? (
<Text>
<MName model={contB} />{" "}
<Text bold>
wins! ({round.scoreB / 100} vs {round.scoreA / 100} votes)
</Text>
</Text>
) : (
<Text bold>
TIE! ({round.scoreA / 100} - {round.scoreB / 100})
</Text>
)}
</Box>
<Box marginLeft={2} gap={1}>
<MName model={contA} />
<Text dimColor>+{round.scoreA}</Text>
<Text dimColor>|</Text>
<MName model={contB} />
<Text dimColor>+{round.scoreB}</Text>
</Box>
<Text dimColor>{"─".repeat(50)}</Text>
</Box>
)}
</Box>
);
}
function Scoreboard({ scores }: { scores: Record<string, number> }) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const maxScore = sorted[0]?.[1] || 1;
const barWidth = 30;
return (
<Box flexDirection="column" marginTop={1}>
<Text bold backgroundColor="magentaBright" color="black">
{" FINAL SCORES "}
</Text>
<Box flexDirection="column" marginTop={1}>
{sorted.map(([name, score], i) => {
const filled = Math.round((score / maxScore) * barWidth);
const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
const medal =
i === 0 ? " 👑" : i === 1 ? " 🥈" : i === 2 ? " 🥉" : "";
return (
<Box key={name} marginLeft={2} gap={1}>
<Text>{String(i + 1).padStart(2)}.</Text>
<Text bold color={MODEL_COLORS[name]}>
{name.padEnd(NAME_PAD)}
</Text>
<Text color={MODEL_COLORS[name]}>{bar}</Text>
<Text bold>
{score} {score === 1 ? "win" : "wins"}
</Text>
<Text>{medal}</Text>
</Box>
);
})}
</Box>
{sorted[0] && sorted[0][1] > 0 && (
<Box marginTop={1} marginLeft={2}>
<Text>
{"🏆 "}
<Text bold color={MODEL_COLORS[sorted[0][0]]}>
{sorted[0][0]}
</Text>
<Text bold> is the funniest AI!</Text>
</Text>
</Box>
)}
</Box>
);
}
function Game({ runs }: { runs: number }) {
const stateRef = useRef<GameState>({
completed: [],
active: null,
scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
viewerScores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
done: false,
isPaused: false,
generation: 0,
});
const [, setTick] = useState(0);
const rerender = useCallback(() => setTick((t) => t + 1), []);
useEffect(() => {
runGame(runs, stateRef.current, rerender).then(() => {
setTimeout(() => process.exit(0), 200);
});
}, []);
const state = stateRef.current;
return (
<Box flexDirection="column">
<Static items={state.completed}>
{(round: RoundState) => (
<RoundView key={round.num} round={round} total={runs} />
)}
</Static>
{state.active && <RoundView round={state.active} total={runs} />}
{state.done && <Scoreboard scores={state.scores} />}
{state.done && (
<Box marginTop={1}>
<Text dimColor>Log: {LOG_FILE}</Text>
</Box>
)}
</Box>
);
}
// ── Main ────────────────────────────────────────────────────────────────────
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);
if (!process.env.OPENROUTER_API_KEY) {
console.error("Error: Set OPENROUTER_API_KEY environment variable");
process.exit(1);
}
log("INFO", "startup", `Game starting: ${runs} rounds`, {
models: MODELS.map((m) => m.id),
});
console.log(
`\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} />);