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 ({elapsed.toFixed(1)}s);
}
function MName({ model, pad }: { model: Model; pad?: boolean }) {
const name = pad ? model.name.padEnd(NAME_PAD) : model.name;
return (
{name}
);
}
function RoundView({ round, total }: { round: RoundState; total: number }) {
const [contA, contB] = round.contestants;
return (
{/* Header */}
{` ROUND ${round.num}${total !== Infinity && total !== null ? `/${total}` : ''} `}
{"─".repeat(50)}
{/* Prompt */}
{" PROMPT "}
{!round.prompt && writing a prompt...}
{round.promptTask.error && (
✗ {round.promptTask.error}
)}
{round.prompt && (
"{round.prompt}"
)}
{/* Answers */}
{round.phase !== "prompting" && (
{" ANSWERS "}
{round.answerTasks.map((task, i) => (
{!task.finishedAt ? (
thinking...
) : task.error ? (
✗ {task.error}
) : (
"{task.result}"
)}
{task.startedAt > 0 && (
)}
))}
)}
{/* Votes */}
{(round.phase === "voting" || round.phase === "done") && (
{" VOTES "}
{round.votes.map((vote, i) => (
{!vote.finishedAt ? (
voting...
) : vote.error || !vote.votedFor ? (
✗ failed
) : (
{"→ "}
)}
))}
)}
{/* Round result */}
{round.phase === "done" &&
round.scoreA !== undefined &&
round.scoreB !== undefined && (
{"─".repeat(50)}
{round.scoreA > round.scoreB ? (
{" "}
wins! ({round.scoreA / 100} vs {round.scoreB / 100} votes)
) : round.scoreB > round.scoreA ? (
{" "}
wins! ({round.scoreB / 100} vs {round.scoreA / 100} votes)
) : (
TIE! ({round.scoreA / 100} - {round.scoreB / 100})
)}
+{round.scoreA}
|
+{round.scoreB}
{"─".repeat(50)}
)}
);
}
function Scoreboard({ scores }: { scores: Record }) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const maxScore = sorted[0]?.[1] || 1;
const barWidth = 30;
return (
{" FINAL SCORES "}
{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 (
{String(i + 1).padStart(2)}.
{name.padEnd(NAME_PAD)}
{bar}
{score} {score === 1 ? 'win' : 'wins'}
{medal}
);
})}
{sorted[0] && sorted[0][1] > 0 && (
{"🏆 "}
{sorted[0][0]}
is the funniest AI!
)}
);
}
function Game({ runs }: { runs: number }) {
const stateRef = useRef({
completed: [],
active: null,
scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
done: false,
isPaused: false,
});
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 (
{(round: RoundState) => (
)}
{state.active && }
{state.done && }
{state.done && (
Log: {LOG_FILE}
)}
);
}
// ── 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[2mAI vs AI comedy showdown — ${runs} rounds\x1b[0m`,
);
console.log(
`\x1b[2mModels: ${MODELS.map((m) => m.name).join(", ")}\x1b[0m\n`,
);
render();