285 lines
9.1 KiB
TypeScript
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} />);
|