This commit is contained in:
Theo Browne
2026-02-19 21:46:52 -08:00
parent 8701a2c0d6
commit f577126081
4 changed files with 764 additions and 327 deletions

View File

@@ -7,9 +7,12 @@
"dependencies": {
"@openrouter/ai-sdk-provider": "^2.2.3",
"ai": "^6.0.94",
"ink": "^6.8.0",
"react": "^19.2.4",
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19.2.14",
},
"peerDependencies": {
"typescript": "^5",
@@ -23,6 +26,8 @@
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
"@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.2.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-NovC+BaCfEeJwhToDrs8JeDYXXlJdEyz7lcxkjtyePSE4eoAKik872SyDK0MzXKcz8MRkv7XlNhPI6zz4TQp0g=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -33,20 +38,104 @@
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"ai": ["ai@6.0.94", "", { "dependencies": { "@ai-sdk/gateway": "3.0.52", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/F9wh262HbK05b/5vILh38JvPiheonT+kBj1L97712E7VPchqmcx7aJuZN3QSk5Pj6knxUJLm2FFpYJI1pHXUA=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
"cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="],
"cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
"code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"es-toolkit": ["es-toolkit@1.44.0", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="],
"escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
"indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
"ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
"string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="],
"type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"cli-truncate/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
}
}

View File

@@ -4,13 +4,16 @@
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
"@types/bun": "latest",
"@types/react": "^19.2.14"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@openrouter/ai-sdk-provider": "^2.2.3",
"ai": "^6.0.94"
"ai": "^6.0.94",
"ink": "^6.8.0",
"react": "^19.2.4"
}
}

View File

@@ -1,325 +0,0 @@
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { generateText } from "ai";
// ── Models ──────────────────────────────────────────────────────────────────
const MODELS = [
{ id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" },
{ id: "moonshotai/kimi-k2", name: "Kimi K2" },
{ id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
{ id: "openai/gpt-5.2", name: "GPT-5.2" },
{ id: "anthropic/claude-opus-4.6", name: "Opus 4.6" },
{ id: "anthropic/claude-sonnet-4.6", name: "Sonnet 4.6" },
{ id: "x-ai/grok-4.1-fast", name: "Grok 4.1" },
] as const;
type Model = (typeof MODELS)[number];
// ── OpenRouter setup ────────────────────────────────────────────────────────
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
function llm(modelId: string) {
return openrouter.chat(modelId);
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function pick<T>(arr: T[], n: number): T[] {
return shuffle(arr).slice(0, n);
}
// ── ANSI colors ─────────────────────────────────────────────────────────────
const c = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
yellow: "\x1b[33m",
cyan: "\x1b[36m",
green: "\x1b[32m",
magenta: "\x1b[35m",
red: "\x1b[31m",
blue: "\x1b[34m",
white: "\x1b[37m",
bgMagenta: "\x1b[45m",
bgBlue: "\x1b[44m",
bgYellow: "\x1b[43m",
bgCyan: "\x1b[46m",
};
// Assign each model a consistent color
const MODEL_COLORS = [c.cyan, c.green, c.magenta, c.yellow, c.blue, c.red, c.white];
function modelColor(model: Model): string {
const idx = MODELS.indexOf(model);
return MODEL_COLORS[idx % MODEL_COLORS.length];
}
function colorName(model: Model): string {
return `${c.bold}${modelColor(model)}${model.name}${c.reset}`;
}
function divider() {
console.log(`${c.dim}${"─".repeat(60)}${c.reset}`);
}
// ── Game logic ──────────────────────────────────────────────────────────────
async function generatePrompt(prompter: Model): Promise<string> {
process.stdout.write(
`\n${c.bold}${c.bgMagenta} PROMPT ${c.reset} ${colorName(prompter)} is writing a prompt...`
);
const { text } = await generateText({
model: llm(prompter.id),
system: `You are a comedy writer for the game Quiplash. Generate a single funny fill-in-the-blank prompt that players will try to answer. The prompt should be surprising and designed to elicit hilarious responses. Return ONLY the prompt text, nothing else. Keep it short (under 15 words).
Use a wide VARIETY of prompt formats. Do NOT always use "The worst thing to..." — mix it up! Here are examples of the range of styles:
- The worst thing to hear from your GPS
- A terrible name for a dog
- A rejected name for a new fast food restaurant
- The worst thing to hear during surgery
- A bad name for a superhero
- A terrible name for a new perfume
- The worst thing to find in your sandwich
- A rejected slogan for a toothpaste brand
- The worst thing to say during a job interview
- A bad name for a country
- The worst thing to say when meeting your partner's parents
- A terrible name for a retirement home
- A rejected title for a romantic comedy
- The world's least popular ice cream flavor
- A terrible fortune cookie message
- What you don't want to hear from your dentist
- The worst name for a band
- A rejected Hallmark card message
- Something you shouldn't yell in a library
- The least intimidating martial arts move
Come up with something ORIGINAL — don't copy these examples.`,
prompt: "Generate a single original Quiplash prompt. Be creative and don't repeat common patterns.",
temperature: 1.2,
maxTokens: 80,
});
const prompt = text.trim().replace(/^["']|["']$/g, "");
console.log(
`\n\n ${c.bold}${c.yellow}"${prompt}"${c.reset}\n`
);
return prompt;
}
async function generateAnswer(
contestant: Model,
prompt: string
): Promise<string> {
const { text } = await generateText({
model: llm(contestant.id),
system: `You are playing Quiplash! You'll be given a fill-in-the-blank prompt. Give the FUNNIEST possible answer. Be creative, edgy, unexpected, and concise. Reply with ONLY your answer — no quotes, no explanation, no preamble. Keep it short (under 12 words).`,
prompt: `Fill in the blank: ${prompt}`,
temperature: 1.2,
maxTokens: 60,
});
return text.trim().replace(/^["']|["']$/g, "");
}
async function getVote(
voter: Model,
prompt: string,
answerA: { model: Model; answer: string },
answerB: { model: Model; answer: string }
): Promise<"A" | "B"> {
const { text } = await generateText({
model: llm(voter.id),
system: `You are a judge in a comedy game. You'll see a fill-in-the-blank prompt and two answers. Pick which answer is FUNNIER. You MUST respond with exactly "A" or "B" — nothing else.`,
prompt: `Prompt: "${prompt}"\n\nAnswer A: "${answerA.answer}"\nAnswer B: "${answerB.answer}"\n\nWhich is funnier? Reply with just A or B.`,
temperature: 0.3,
maxTokens: 5,
});
const vote = text.trim().toUpperCase();
return vote.startsWith("A") ? "A" : "B";
}
async function playRound(
roundNum: number,
totalRounds: number,
scores: Map<string, number>
) {
console.log(
`\n${c.bold}${c.bgBlue} ROUND ${roundNum}/${totalRounds} ${c.reset}`
);
divider();
// Pick roles
const shuffled = shuffle([...MODELS]);
const prompter = shuffled[0];
const contestantA = shuffled[1];
const contestantB = shuffled[2];
const voters = shuffled.slice(3);
// 1. Generate prompt
const prompt = await generatePrompt(prompter);
// 2. Get answers (in parallel)
process.stdout.write(
`${c.dim} ${contestantA.name} and ${contestantB.name} are thinking...${c.reset}`
);
const [answerA, answerB] = await Promise.all([
generateAnswer(contestantA, prompt),
generateAnswer(contestantB, prompt),
]);
process.stdout.write("\r\x1b[K"); // clear the "thinking" line
console.log(`${c.bold}${c.bgCyan} ANSWERS ${c.reset}`);
console.log(` ${colorName(contestantA)} "${c.bold}${answerA}${c.reset}"`);
console.log(` ${colorName(contestantB)} "${c.bold}${answerB}${c.reset}"`);
// 3. Voting
console.log(`\n${c.bold}${c.bgYellow}${c.red} VOTES ${c.reset}`);
const voteResults = await Promise.all(
voters.map(async (voter) => {
// Randomize presentation order to avoid position bias
const showAFirst = Math.random() > 0.5;
const first = showAFirst
? { model: contestantA, answer: answerA }
: { model: contestantB, answer: answerB };
const second = showAFirst
? { model: contestantB, answer: answerB }
: { model: contestantA, answer: answerA };
const vote = await getVote(voter, prompt, first, second);
// Map back to actual contestant
const votedFor = showAFirst
? vote === "A"
? contestantA
: contestantB
: vote === "A"
? contestantB
: contestantA;
return { voter, votedFor };
})
);
// Display votes
let votesA = 0;
let votesB = 0;
for (const { voter, votedFor } of voteResults) {
const arrow =
votedFor === contestantA ? colorName(contestantA) : colorName(contestantB);
console.log(` ${colorName(voter)}${arrow}`);
if (votedFor === contestantA) votesA++;
else votesB++;
}
// Update scores
scores.set(
contestantA.name,
(scores.get(contestantA.name) ?? 0) + votesA * 100
);
scores.set(
contestantB.name,
(scores.get(contestantB.name) ?? 0) + votesB * 100
);
// Round result
console.log();
divider();
const winner =
votesA > votesB
? contestantA
: votesB > votesA
? contestantB
: null;
if (winner) {
console.log(
` ${colorName(winner)} wins! ${c.bold}(${Math.max(votesA, votesB)} votes vs ${Math.min(votesA, votesB)})${c.reset}`
);
} else {
console.log(` ${c.bold}TIE!${c.reset} (${votesA} - ${votesB})`);
}
console.log(
` ${colorName(contestantA)} ${c.dim}+${votesA * 100}${c.reset} | ${colorName(contestantB)} ${c.dim}+${votesB * 100}${c.reset}`
);
divider();
}
function printScoreboard(scores: Map<string, number>) {
console.log(`\n${c.bold}${c.bgMagenta} FINAL SCORES ${c.reset}\n`);
const sorted = [...scores.entries()].sort((a, b) => b[1] - a[1]);
const maxScore = sorted[0]?.[1] ?? 0;
const barWidth = 30;
for (let i = 0; i < sorted.length; i++) {
const [name, score] = sorted[i];
const model = MODELS.find((m) => m.name === name)!;
const filled = maxScore > 0 ? Math.round((score / maxScore) * barWidth) : 0;
const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
const medal = i === 0 ? " 👑" : i === 1 ? " 🥈" : i === 2 ? " 🥉" : "";
const color = modelColor(model);
console.log(
` ${c.bold}${i + 1}.${c.reset} ${color}${c.bold}${name.padEnd(16)}${c.reset} ${color}${bar}${c.reset} ${c.bold}${score}${c.reset}${medal}`
);
}
const winner = sorted[0];
if (winner) {
const model = MODELS.find((m) => m.name === winner[0])!;
console.log(
`\n ${c.bold}🏆 ${modelColor(model)}${winner[0]}${c.reset}${c.bold} is the funniest AI!${c.reset}\n`
);
}
}
// ── Main ────────────────────────────────────────────────────────────────────
async function main() {
const runsArg = process.argv.find((a) => a.startsWith("runs="));
const runs = runsArg ? parseInt(runsArg.split("=")[1], 10) : 5;
if (!process.env.OPENROUTER_API_KEY) {
console.error(
`${c.red}${c.bold}Error:${c.reset} Set OPENROUTER_API_KEY environment variable`
);
process.exit(1);
}
console.log(`\n${c.bold}${c.bgMagenta} QUIPSLOP ${c.reset}`);
console.log(`${c.dim} AI vs AI comedy showdown — ${runs} rounds${c.reset}`);
console.log(
`${c.dim} Models: ${MODELS.map((m) => m.name).join(", ")}${c.reset}`
);
const scores = new Map<string, number>();
for (const m of MODELS) scores.set(m.name, 0);
for (let i = 1; i <= runs; i++) {
await playRound(i, runs, scores);
}
printScoreboard(scores);
}
main().catch((err) => {
console.error(`\n${c.red}${c.bold}Fatal error:${c.reset}`, err);
process.exit(1);
});

670
quipslop.tsx Normal file
View File

@@ -0,0 +1,670 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { render, Box, Text, Static, useApp } from "ink";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { generateText } from "ai";
import { mkdirSync, appendFileSync } from "node:fs";
import { join } from "node:path";
// ── Models ──────────────────────────────────────────────────────────────────
const MODELS = [
{ id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" },
{ id: "moonshotai/kimi-k2", name: "Kimi K2" },
// { id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
{ id: "deepseek/deepseek-v3.2", name: "DeepSeek 3.2" },
{ id: "z-ai/glm-5", name: "GLM-5" },
{ id: "openai/gpt-5.2", name: "GPT-5.2" },
{ id: "anthropic/claude-opus-4.6", name: "Opus 4.6" },
{ id: "anthropic/claude-sonnet-4.6", name: "Sonnet 4.6" },
{ id: "x-ai/grok-4.1-fast", name: "Grok 4.1" },
] as const;
type Model = (typeof MODELS)[number];
const MODEL_COLORS: Record<string, string> = {
"Gemini 3.1 Pro": "cyan",
"Kimi K2": "green",
"Kimi K2.5": "magenta",
"DeepSeek 3.2": "greenBright",
"GLM-5": "cyanBright",
"GPT-5.2": "yellow",
"Opus 4.6": "blue",
"Sonnet 4.6": "red",
"Grok 4.1": "white",
};
const NAME_PAD = 16;
// ── OpenRouter ──────────────────────────────────────────────────────────────
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
// ── Types ───────────────────────────────────────────────────────────────────
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";
prompter: Model;
promptTask: TaskInfo;
prompt?: string;
contestants: [Model, Model];
answerTasks: [TaskInfo, TaskInfo];
votes: VoteInfo[];
scoreA?: number;
scoreB?: number;
};
type GameState = {
completed: RoundState[];
active: RoundState | null;
scores: Record<string, number>;
done: boolean;
};
// ── Logger ──────────────────────────────────────────────────────────────────
const LOGS_DIR = join(import.meta.dir, "logs");
mkdirSync(LOGS_DIR, { recursive: true });
const LOG_FILE = join(
LOGS_DIR,
`game-${new Date().toISOString().replace(/[:.]/g, "-")}.log`,
);
function log(
level: "INFO" | "WARN" | "ERROR",
category: string,
message: string,
data?: Record<string, unknown>,
) {
const ts = new Date().toISOString();
let line = `[${ts}] ${level} [${category}] ${message}`;
if (data) {
line += "\n " + JSON.stringify(data, null, 2).replace(/\n/g, "\n ");
}
appendFileSync(LOG_FILE, line + "\n");
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j]!, a[i]!];
}
return a;
}
async function withRetry<T>(
fn: () => Promise<T>,
validate: (result: T) => boolean,
retries = 3,
label = "unknown",
): Promise<T> {
let lastErr: unknown;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const result = await fn();
if (validate(result)) {
log("INFO", label, `Success on attempt ${attempt}`, {
result: typeof result === "string" ? result : String(result),
});
return result;
}
const msg = `Validation failed (attempt ${attempt}/${retries})`;
log("WARN", label, msg, {
result: typeof result === "string" ? result : String(result),
});
lastErr = new Error(`${msg}: ${JSON.stringify(result).slice(0, 100)}`);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log("WARN", label, `Error on attempt ${attempt}/${retries}: ${errMsg}`, {
error: errMsg,
stack: err instanceof Error ? err.stack : undefined,
});
lastErr = err;
}
if (attempt < retries) {
await new Promise((r) => setTimeout(r, 1000 * attempt));
}
}
log("ERROR", label, `All ${retries} attempts failed`, {
lastError: lastErr instanceof Error ? lastErr.message : String(lastErr),
});
throw lastErr;
}
// Minimum length for a real response (not junk like "The" or "")
function isRealString(s: string, minLength = 5): boolean {
return s.length >= minLength;
}
function cleanResponse(text: string): string {
return text.trim().replace(/^["']|["']$/g, "");
}
// ── AI functions ────────────────────────────────────────────────────────────
const PROMPT_SYSTEM = `You are a comedy writer for the game Quiplash. Generate a single funny fill-in-the-blank prompt that players will try to answer. The prompt should be surprising and designed to elicit hilarious responses. Return ONLY the prompt text, nothing else. Keep it short (under 15 words).
Use a wide VARIETY of prompt formats. Do NOT always use "The worst thing to..." — mix it up! Here are examples of the range of styles:
- The worst thing to hear from your GPS
- A terrible name for a dog
- A rejected name for a new fast food restaurant
- The worst thing to hear during surgery
- A bad name for a superhero
- A terrible name for a new perfume
- The worst thing to find in your sandwich
- A rejected slogan for a toothpaste brand
- The worst thing to say during a job interview
- A bad name for a country
- The worst thing to say when meeting your partner's parents
- A terrible name for a retirement home
- A rejected title for a romantic comedy
- The world's least popular ice cream flavor
- A terrible fortune cookie message
- What you don't want to hear from your dentist
- The worst name for a band
- A rejected Hallmark card message
- Something you shouldn't yell in a library
- The least intimidating martial arts move
Come up with something ORIGINAL — don't copy these examples.`;
async function callGeneratePrompt(model: Model): Promise<string> {
log("INFO", `prompt:${model.name}`, "Calling API", { modelId: model.id });
const { text, usage } = await generateText({
model: openrouter.chat(model.id),
system: PROMPT_SYSTEM,
prompt:
"Generate a single original Quiplash prompt. Be creative and don't repeat common patterns.",
// temperature: 1.2,
// maxOutputTokens: 80,
});
log("INFO", `prompt:${model.name}`, "Raw response", {
rawText: text,
usage,
});
return cleanResponse(text);
}
async function callGenerateAnswer(
model: Model,
prompt: string,
): Promise<string> {
log("INFO", `answer:${model.name}`, "Calling API", {
modelId: model.id,
prompt,
});
const { text, usage } = await generateText({
model: openrouter.chat(model.id),
system: `You are playing Quiplash! You'll be given a fill-in-the-blank prompt. Give the FUNNIEST possible answer. Be creative, edgy, unexpected, and concise. Reply with ONLY your answer — no quotes, no explanation, no preamble. Keep it short (under 12 words).`,
prompt: `Fill in the blank: ${prompt}`,
// temperature: 1.2,
// maxOutputTokens: 60,
});
log("INFO", `answer:${model.name}`, "Raw response", {
rawText: text,
usage,
});
return cleanResponse(text);
}
async function callVote(
voter: Model,
prompt: string,
a: { answer: string },
b: { answer: string },
): Promise<"A" | "B"> {
log("INFO", `vote:${voter.name}`, "Calling API", {
modelId: voter.id,
prompt,
answerA: a.answer,
answerB: b.answer,
});
const { text, usage } = await generateText({
model: openrouter.chat(voter.id),
system: `You are a judge in a comedy game. You'll see a fill-in-the-blank prompt and two answers. Pick which answer is FUNNIER. You MUST respond with exactly "A" or "B" — nothing else.`,
prompt: `Prompt: "${prompt}"\n\nAnswer A: "${a.answer}"\nAnswer B: "${b.answer}"\n\nWhich is funnier? Reply with just A or B.`,
// temperature: 0.3,
// maxOutputTokens: 5,
});
log("INFO", `vote:${voter.name}`, "Raw response", { rawText: text, usage });
const cleaned = text.trim().toUpperCase();
if (!cleaned.startsWith("A") && !cleaned.startsWith("B")) {
throw new Error(`Invalid vote: "${text.trim()}"`);
}
return cleaned.startsWith("A") ? "A" : "B";
}
// ── Game loop ───────────────────────────────────────────────────────────────
async function runGame(runs: number, state: GameState, rerender: () => void) {
for (let r = 1; r <= runs; r++) {
const shuffled = shuffle([...MODELS]);
const prompter = shuffled[0]!;
const contA = shuffled[1]!;
const contB = shuffled[2]!;
const voters = shuffled.slice(3);
const now = Date.now();
// Initialize round
const round: RoundState = {
num: r,
phase: "prompting",
prompter,
promptTask: { model: prompter, startedAt: now },
contestants: [contA, contB],
answerTasks: [
{ model: contA, startedAt: 0 },
{ model: contB, startedAt: 0 },
],
votes: [],
};
state.active = round;
log("INFO", "round", `=== Round ${r}/${runs} ===`, {
prompter: prompter.name,
contestants: [contA.name, contB.name],
voters: voters.map((v) => v.name),
});
rerender();
// ── Prompt phase ──
try {
const prompt = await withRetry(
() => callGeneratePrompt(prompter),
(s) => isRealString(s, 10),
3,
`R${r}:prompt:${prompter.name}`,
);
round.promptTask.finishedAt = Date.now();
round.promptTask.result = prompt;
round.prompt = prompt;
rerender();
} catch {
round.promptTask.finishedAt = Date.now();
round.promptTask.error = "Failed after 3 attempts";
round.phase = "done";
state.completed = [...state.completed, round];
state.active = null;
rerender();
continue;
}
// ── Answer phase ──
round.phase = "answering";
const answerStart = Date.now();
round.answerTasks[0].startedAt = answerStart;
round.answerTasks[1].startedAt = answerStart;
rerender();
await Promise.all(
round.answerTasks.map(async (task) => {
try {
const answer = await withRetry(
() => callGenerateAnswer(task.model, round.prompt!),
(s) => isRealString(s, 3),
3,
`R${r}:answer:${task.model.name}`,
);
task.result = answer;
} catch {
task.error = "Failed to answer";
task.result = "[no answer]";
}
task.finishedAt = Date.now();
rerender();
}),
);
// ── Vote phase ──
round.phase = "voting";
const answerA = round.answerTasks[0].result!;
const answerB = round.answerTasks[1].result!;
const voteStart = Date.now();
round.votes = voters.map((v) => ({ voter: v, startedAt: voteStart }));
rerender();
await Promise.all(
round.votes.map(async (vote) => {
try {
const showAFirst = Math.random() > 0.5;
const first = showAFirst ? { answer: answerA } : { answer: answerB };
const second = showAFirst ? { answer: answerB } : { answer: answerA };
const result = await withRetry(
() => callVote(vote.voter, round.prompt!, first, second),
(v) => v === "A" || v === "B",
3,
`R${r}:vote:${vote.voter.name}`,
);
const votedFor = showAFirst
? result === "A"
? contA
: contB
: result === "A"
? contB
: contA;
vote.finishedAt = Date.now();
vote.votedFor = votedFor;
} catch {
vote.finishedAt = Date.now();
vote.error = true;
}
rerender();
}),
);
// ── Score ──
let votesA = 0;
let votesB = 0;
for (const v of round.votes) {
if (v.votedFor === contA) votesA++;
else if (v.votedFor === contB) votesB++;
}
round.scoreA = votesA * 100;
round.scoreB = votesB * 100;
round.phase = "done";
state.scores[contA.name] = (state.scores[contA.name] || 0) + round.scoreA;
state.scores[contB.name] = (state.scores[contB.name] || 0) + round.scoreB;
rerender();
// Brief pause so the user can see the result
await new Promise((r) => setTimeout(r, 2000));
// Archive round
state.completed = [...state.completed, round];
state.active = null;
rerender();
}
state.done = true;
rerender();
}
// ── 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 inverse backgroundColor="blue">
{` ROUND ${round.num}/${total} `}
</Text>
</Box>
<Text dimColor>{"─".repeat(50)}</Text>
{/* Prompt */}
<Box marginTop={1} gap={1}>
<Text bold inverse backgroundColor="magenta">
{" 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 inverse backgroundColor="cyan">
{" 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 inverse backgroundColor="yellow" color="red">
{" 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 inverse backgroundColor="magenta">
{" 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}</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])),
done: 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 (
<Box flexDirection="column">
<Box marginBottom={1} gap={1}>
<Text bold inverse backgroundColor="magenta">
{" QUIPSLOP "}
</Text>
<Text dimColor>AI vs AI comedy showdown {runs} rounds</Text>
</Box>
<Box marginBottom={1}>
<Text dimColor>Models: {MODELS.map((m) => m.name).join(", ")}</Text>
</Box>
<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 runs = runsArg ? parseInt(runsArg.split("=")[1] ?? "5", 10) : 5;
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),
});
render(<Game runs={runs} />);