diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a8fa858 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# AGENTS.md + +## Commands +```bash +bun dev # Dev server (Turbopack) +bun build # Production build +bun lint # ESLint +bun db:push # Push schema to database +bun db:studio # Drizzle Studio +``` +No test framework configured. + +## Code Style +- **Imports**: Use `@/*` path alias (e.g., `@/lib/utils`, `@/components/ui/button`) +- **Components**: `"use client"` directive for client components; use `cn()` from `@/lib/utils` for className merging +- **Types**: Strict TypeScript; infer DB types via `$inferSelect`/`$inferInsert`; use Zod for runtime validation +- **Naming**: camelCase for functions/variables, PascalCase for components/types, SCREAMING_SNAKE for constants +- **Error handling**: try/catch with `Response.json({ error }, { status })` in API routes +- **No comments** unless explicitly requested +- **Formatting**: 2-space indent, double quotes for JSX strings, template literals for interpolation +- **AI SDK**: Use `@ai-sdk/gateway` with model format `provider/model-name` (e.g., `openai/gpt-4o`) +- **UI**: shadcn/ui components from `@/components/ui/*`; Lucide icons diff --git a/app/api/gallery/[id]/route.ts b/app/api/gallery/[id]/route.ts index 67cca64..04c2f46 100644 --- a/app/api/gallery/[id]/route.ts +++ b/app/api/gallery/[id]/route.ts @@ -1,7 +1,13 @@ -import { eq } from "drizzle-orm"; +import { asc, eq } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; import { db } from "@/db"; -import { drawings, gameSessions, guesses } from "@/db/schema"; +import { + drawings, + gameSessions, + guesses, + playerScores, + roundResults, +} from "@/db/schema"; export async function GET( _request: NextRequest, @@ -19,11 +25,72 @@ export async function GET( return NextResponse.json({ error: "Session not found" }, { status: 404 }); } - const [sessionDrawings, sessionGuesses] = await Promise.all([ + const [sessionRounds, sessionDrawings, sessionGuesses] = await Promise.all([ + db + .select() + .from(roundResults) + .where(eq(roundResults.sessionId, id)) + .orderBy(asc(roundResults.roundNumber)), db.select().from(drawings).where(eq(drawings.sessionId, id)), db.select().from(guesses).where(eq(guesses.sessionId, id)), ]); + let playerName: string | null = null; + if (session.clerkUserId) { + const [player] = await db + .select() + .from(playerScores) + .where(eq(playerScores.clerkUserId, session.clerkUserId)) + .limit(1); + playerName = player?.username || null; + } else if (session.anonId) { + const [player] = await db + .select() + .from(playerScores) + .where(eq(playerScores.anonId, session.anonId)) + .limit(1); + playerName = player?.username || null; + } + + const rounds = sessionRounds.map((round) => { + const roundGuesses = sessionGuesses.filter((g) => g.roundId === round.id); + + const llmDrawing = sessionDrawings.find( + (d) => round.drawerType === "llm" && d.modelId === round.drawerId, + ); + + return { + id: round.id, + roundNumber: round.roundNumber, + prompt: round.prompt, + drawerType: round.drawerType, + drawerId: round.drawerId, + svg: llmDrawing?.svg || round.svg, + chunks: llmDrawing?.chunks ? JSON.parse(llmDrawing.chunks) : null, + guesses: roundGuesses.map((g) => ({ + id: g.id, + modelId: g.modelId, + guess: g.guess, + isCorrect: g.isCorrect, + isHuman: g.isHuman, + semanticScore: g.semanticScore, + finalScore: g.finalScore, + generationTimeMs: g.generationTimeMs, + })), + }; + }); + + const legacyGuesses = + sessionRounds.length === 0 + ? sessionGuesses.map((g) => ({ + id: g.id, + modelId: g.modelId, + guess: g.guess, + isCorrect: g.isCorrect, + generationTimeMs: g.generationTimeMs, + })) + : []; + return NextResponse.json({ id: session.id, mode: session.mode, @@ -32,6 +99,8 @@ export async function GET( totalTokens: session.totalTokens || 0, totalTimeMs: session.totalTimeMs, createdAt: session.createdAt.toISOString(), + playerName, + rounds, drawings: sessionDrawings.map((d) => ({ id: d.id, modelId: d.modelId, @@ -41,13 +110,7 @@ export async function GET( isWinner: d.isWinner, chunks: d.chunks ? JSON.parse(d.chunks) : null, })), - guesses: sessionGuesses.map((g) => ({ - id: g.id, - modelId: g.modelId, - guess: g.guess, - isCorrect: g.isCorrect, - generationTimeMs: g.generationTimeMs, - })), + guesses: legacyGuesses, }); } catch (error) { console.error("Error fetching session:", error); diff --git a/app/api/gallery/route.ts b/app/api/gallery/route.ts index 51f333f..5caebda 100644 --- a/app/api/gallery/route.ts +++ b/app/api/gallery/route.ts @@ -1,12 +1,19 @@ -import { and, count, desc, eq, inArray, sql } from "drizzle-orm"; +import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { db } from "@/db"; -import { drawings, gameSessions, guesses, modelScores } from "@/db/schema"; +import { + drawings, + gameSessions, + guesses, + modelScores, + playerScores, + roundResults, +} from "@/db/schema"; import { getCurrentIdentity } from "@/lib/identity"; const SaveSessionSchema = z.object({ - mode: z.enum(["human_judge", "model_guess", "ai_duel"]), + mode: z.enum(["pictionary", "human_judge", "model_guess", "ai_duel"]), prompt: z.string(), totalCost: z.number(), totalTokens: z.number(), @@ -28,6 +35,10 @@ const SaveSessionSchema = z.object({ modelId: z.string(), guess: z.string(), isCorrect: z.boolean(), + semanticScore: z.number().optional(), + timeBonus: z.number().optional(), + finalScore: z.number().optional(), + isHuman: z.boolean().optional(), generationTimeMs: z.number().optional(), cost: z.number().optional(), tokens: z.number().optional(), @@ -84,20 +95,54 @@ export async function GET(request: NextRequest) { const sessionIds = sessions.map((s) => s.id); - const [allDrawings, allGuesses] = await Promise.all([ - sessionIds.length > 0 - ? db - .select() - .from(drawings) - .where(inArray(drawings.sessionId, sessionIds)) - : [], - sessionIds.length > 0 - ? db - .select() - .from(guesses) - .where(inArray(guesses.sessionId, sessionIds)) - : [], - ]); + const clerkUserIds = sessions + .map((s) => s.clerkUserId) + .filter((id): id is string => id !== null); + const anonIds = sessions + .map((s) => s.anonId) + .filter((id): id is string => id !== null); + + const [allDrawings, allGuesses, allRounds, allPlayerScores] = + await Promise.all([ + sessionIds.length > 0 + ? db + .select() + .from(drawings) + .where(inArray(drawings.sessionId, sessionIds)) + : [], + sessionIds.length > 0 + ? db + .select() + .from(guesses) + .where(inArray(guesses.sessionId, sessionIds)) + : [], + sessionIds.length > 0 + ? db + .select() + .from(roundResults) + .where(inArray(roundResults.sessionId, sessionIds)) + .orderBy(asc(roundResults.roundNumber)) + : [], + clerkUserIds.length > 0 || anonIds.length > 0 + ? db.select().from(playerScores) + : [], + ]); + + const playersByClerkId = allPlayerScores.reduce( + (acc, p) => { + if (p.clerkUserId) acc[p.clerkUserId] = p; + return acc; + }, + {} as Record, + ); + + const playersByAnonId = allPlayerScores.reduce( + (acc, p) => { + if (p.anonId) acc[p.anonId] = p; + return acc; + }, + {} as Record, + ); const drawingsBySession = allDrawings.reduce( (acc, d) => { @@ -117,30 +162,69 @@ export async function GET(request: NextRequest) { {} as Record>, ); - const items = sessions.map((session) => ({ - id: session.id, - mode: session.mode, - prompt: session.prompt, - totalCost: session.totalCost || 0, - totalTokens: session.totalTokens || 0, - totalTimeMs: session.totalTimeMs, - createdAt: session.createdAt.toISOString(), - drawings: (drawingsBySession[session.id] || []).map((d) => ({ - id: d.id, - modelId: d.modelId, - svg: d.svg, - generationTimeMs: d.generationTimeMs, - cost: d.cost, - isWinner: d.isWinner, - })), - guesses: (guessesBySession[session.id] || []).map((g) => ({ - id: g.id, - modelId: g.modelId, - guess: g.guess, - isCorrect: g.isCorrect, - generationTimeMs: g.generationTimeMs, - })), - })); + const roundsBySession = allRounds.reduce( + (acc, r) => { + if (!acc[r.sessionId]) acc[r.sessionId] = []; + acc[r.sessionId].push(r); + return acc; + }, + {} as Record>, + ); + + const items = sessions.map((session) => { + const sessionDrawings = drawingsBySession[session.id] || []; + const sessionRounds = roundsBySession[session.id] || []; + + const player = session.clerkUserId + ? playersByClerkId[session.clerkUserId] + : session.anonId + ? playersByAnonId[session.anonId] + : null; + + const allSessionDrawings = [ + ...sessionDrawings.map((d) => ({ + id: d.id, + modelId: d.modelId, + svg: d.svg, + generationTimeMs: d.generationTimeMs, + cost: d.cost, + isWinner: d.isWinner, + chunks: d.chunks ? JSON.parse(d.chunks as string) : null, + isHumanDrawing: false, + })), + ...sessionRounds + .filter((r) => r.drawerType === "human" && r.svg) + .map((r) => ({ + id: r.id, + modelId: "human", + svg: r.svg as string, + generationTimeMs: null, + cost: null, + isWinner: false, + chunks: null, + isHumanDrawing: true, + })), + ]; + + return { + id: session.id, + mode: session.mode, + prompt: session.prompt, + totalCost: session.totalCost || 0, + totalTokens: session.totalTokens || 0, + totalTimeMs: session.totalTimeMs, + createdAt: session.createdAt.toISOString(), + playerName: player?.username || null, + drawings: allSessionDrawings, + guesses: (guessesBySession[session.id] || []).map((g) => ({ + id: g.id, + modelId: g.modelId, + guess: g.guess, + isCorrect: g.isCorrect, + generationTimeMs: g.generationTimeMs, + })), + }; + }); return NextResponse.json({ items, @@ -200,6 +284,10 @@ export async function POST(request: NextRequest) { modelId: g.modelId, guess: g.guess, isCorrect: g.isCorrect, + semanticScore: g.semanticScore, + timeBonus: g.timeBonus, + finalScore: g.finalScore, + isHuman: g.isHuman, generationTimeMs: g.generationTimeMs, cost: g.cost, tokens: g.tokens, @@ -235,6 +323,10 @@ async function updateModelScores(data: z.infer) { modelGuessCorrect?: number; aiDuelPoints?: number; aiDuelRounds?: number; + drawingScore?: number; + guessingScore?: number; + drawingRounds?: number; + guessingRounds?: number; cost: number; tokens: number; } @@ -331,6 +423,30 @@ async function updateModelScores(data: z.infer) { aiDuelPoints: (existing.aiDuelPoints || 0) + 1, }); } + } else if (data.mode === "pictionary") { + data.drawings.forEach((d) => { + const existing = modelUpdates.get(d.modelId) || { cost: 0, tokens: 0 }; + const drawerBonus = + data.guesses?.reduce((sum, g) => { + if (g.modelId === d.modelId) return sum; + const multiplier = g.isHuman ? 1.5 : 1; + return sum + ((g.semanticScore || 0) > 0.7 ? 10 * multiplier : 0); + }, 0) || 0; + modelUpdates.set(d.modelId, { + ...existing, + drawingScore: (existing.drawingScore || 0) + drawerBonus, + drawingRounds: (existing.drawingRounds || 0) + 1, + }); + }); + + data.guesses?.forEach((g) => { + const existing = modelUpdates.get(g.modelId) || { cost: 0, tokens: 0 }; + modelUpdates.set(g.modelId, { + ...existing, + guessingScore: (existing.guessingScore || 0) + (g.semanticScore || 0), + guessingRounds: (existing.guessingRounds || 0) + 1, + }); + }); } for (const [modelId, updates] of modelUpdates.entries()) { @@ -344,6 +460,10 @@ async function updateModelScores(data: z.infer) { modelGuessCorrect: updates.modelGuessCorrect || 0, aiDuelPoints: updates.aiDuelPoints || 0, aiDuelRounds: updates.aiDuelRounds || 0, + drawingScore: updates.drawingScore || 0, + guessingScore: updates.guessingScore || 0, + drawingRounds: updates.drawingRounds || 0, + guessingRounds: updates.guessingRounds || 0, totalCost: updates.cost, totalTokens: updates.tokens, }) @@ -356,6 +476,10 @@ async function updateModelScores(data: z.infer) { modelGuessTotal: sql`${modelScores.modelGuessTotal} + ${updates.modelGuessTotal || 0}`, aiDuelPoints: sql`${modelScores.aiDuelPoints} + ${updates.aiDuelPoints || 0}`, aiDuelRounds: sql`${modelScores.aiDuelRounds} + ${updates.aiDuelRounds || 0}`, + drawingScore: sql`${modelScores.drawingScore} + ${updates.drawingScore || 0}`, + guessingScore: sql`${modelScores.guessingScore} + ${updates.guessingScore || 0}`, + drawingRounds: sql`${modelScores.drawingRounds} + ${updates.drawingRounds || 0}`, + guessingRounds: sql`${modelScores.guessingRounds} + ${updates.guessingRounds || 0}`, totalCost: sql`${modelScores.totalCost} + ${updates.cost}`, totalTokens: sql`${modelScores.totalTokens} + ${updates.tokens}`, updatedAt: new Date(), diff --git a/app/api/leaderboard/route.ts b/app/api/leaderboard/route.ts index 4eb3972..a3acf57 100644 --- a/app/api/leaderboard/route.ts +++ b/app/api/leaderboard/route.ts @@ -1,20 +1,28 @@ import { count } from "drizzle-orm"; -import { NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; import { db } from "@/db"; -import { gameSessions, modelScores } from "@/db/schema"; +import { gameSessions, modelScores, playerScores } from "@/db/schema"; import { getModelById } from "@/lib/models"; -export async function GET() { +export async function GET(request: NextRequest) { try { - const [scores, totalSessionsResult] = await Promise.all([ + const searchParams = request.nextUrl.searchParams; + const type = searchParams.get("type") || "combined"; + + const [llmScores, humanScores, totalSessionsResult] = await Promise.all([ db.select().from(modelScores), + db.select().from(playerScores), db.select({ count: count() }).from(gameSessions), ]); const totalSessions = totalSessionsResult[0]?.count || 0; - const models = scores + const models = llmScores .map((score) => { + const drawingScore = score.drawingScore ?? 0; + const guessingScore = score.guessingScore ?? 0; + const drawingRounds = score.drawingRounds ?? 0; + const guessingRounds = score.guessingRounds ?? 0; const humanJudgePlays = score.humanJudgePlays ?? 0; const humanJudgeWins = score.humanJudgeWins ?? 0; const modelGuessTotal = score.modelGuessTotal ?? 0; @@ -22,19 +30,34 @@ export async function GET() { const aiDuelRounds = score.aiDuelRounds ?? 0; const aiDuelPoints = score.aiDuelPoints ?? 0; + const avgDrawingScore = + drawingRounds > 0 ? drawingScore / drawingRounds : 0; + const avgGuessingScore = + guessingRounds > 0 ? guessingScore / guessingRounds : 0; + const humanJudgeWinRate = humanJudgePlays > 0 ? (humanJudgeWins / humanJudgePlays) * 100 : 0; const modelGuessAccuracy = modelGuessTotal > 0 ? (modelGuessCorrect / modelGuessTotal) * 100 : 0; + const totalRounds = drawingRounds + guessingRounds; const overallScore = - humanJudgeWinRate * 0.4 + - modelGuessAccuracy * 0.4 + - (aiDuelRounds > 0 ? (aiDuelPoints / aiDuelRounds) * 10 : 0) * 0.2; + totalRounds > 0 + ? (avgDrawingScore * 0.5 + avgGuessingScore * 0.5) * 10 + : humanJudgeWinRate * 0.4 + + modelGuessAccuracy * 0.4 + + (aiDuelRounds > 0 ? (aiDuelPoints / aiDuelRounds) * 10 : 0) * 0.2; return { + id: score.modelId, + type: "llm" as const, + name: getModelById(score.modelId)?.name || score.modelId, modelId: score.modelId, + drawingScore: Math.round(avgDrawingScore * 100) / 100, + guessingScore: Math.round(avgGuessingScore * 100) / 100, + drawingRounds, + guessingRounds, humanJudgeWins, humanJudgePlays, humanJudgeWinRate: Math.round(humanJudgeWinRate * 100) / 100, @@ -46,17 +69,70 @@ export async function GET() { totalCost: score.totalCost ?? 0, totalTokens: score.totalTokens ?? 0, overallScore: Math.round(overallScore * 100) / 100, + totalScore: score.drawingScore ?? 0 + (score.guessingScore ?? 0), + roundsPlayed: totalRounds, + gamesPlayed: 0, + gamesWon: 0, + bestRoundScore: 0, }; }) .filter((m) => { const model = getModelById(m.modelId); return model !== undefined; - }) - .sort((a, b) => b.overallScore - a.overallScore); + }); + + const players = humanScores.map((score) => { + const drawingRounds = score.drawingRounds ?? 0; + const guessingRounds = score.guessingRounds ?? 0; + const avgDrawingScore = + drawingRounds > 0 ? (score.drawingScore ?? 0) / drawingRounds : 0; + const avgGuessingScore = + guessingRounds > 0 ? (score.guessingScore ?? 0) / guessingRounds : 0; + const totalRounds = drawingRounds + guessingRounds; + const overallScore = + totalRounds > 0 + ? (avgDrawingScore * 0.5 + avgGuessingScore * 0.5) * 10 + : 0; + + return { + id: score.id, + type: "human" as const, + name: score.username || "Anonymous Player", + clerkUserId: score.clerkUserId, + anonId: score.anonId, + drawingScore: Math.round(avgDrawingScore * 100) / 100, + guessingScore: Math.round(avgGuessingScore * 100) / 100, + drawingRounds, + guessingRounds, + overallScore: Math.round(overallScore * 100) / 100, + totalScore: score.totalScore ?? 0, + roundsPlayed: score.roundsPlayed ?? 0, + gamesPlayed: score.gamesPlayed ?? 0, + gamesWon: score.gamesWon ?? 0, + bestRoundScore: score.bestRoundScore ?? 0, + totalCost: 0, + totalTokens: 0, + }; + }); + + let result; + if (type === "llm") { + result = models.sort((a, b) => b.overallScore - a.overallScore); + } else if (type === "human") { + result = players.sort((a, b) => b.overallScore - a.overallScore); + } else { + const combined = [...models, ...players].sort( + (a, b) => b.overallScore - a.overallScore, + ); + result = combined; + } return NextResponse.json({ - models, + entries: result, + models: models.sort((a, b) => b.overallScore - a.overallScore), + players: players.sort((a, b) => b.overallScore - a.overallScore), totalSessions, + type, }); } catch (error) { console.error("Error fetching leaderboard:", error); diff --git a/app/api/rounds/route.ts b/app/api/rounds/route.ts new file mode 100644 index 0000000..1bbedd4 --- /dev/null +++ b/app/api/rounds/route.ts @@ -0,0 +1,274 @@ +import { sql } from "drizzle-orm"; +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db } from "@/db"; +import { + drawings, + guesses, + modelScores, + playerScores, + roundResults, +} from "@/db/schema"; +import { getCurrentIdentity } from "@/lib/identity"; + +const MAX_SCORE_PER_GUESSER = 180; + +const SaveRoundSchema = z.object({ + sessionId: z.uuid(), + roundNumber: z.number(), + drawerId: z.string(), + drawerType: z.enum(["human", "llm"]), + prompt: z.string(), + svg: z.string().optional(), + imageDataUrl: z.string().optional(), + guesserCount: z.number(), + drawing: z + .object({ + modelId: z.string(), + svg: z.string(), + generationTimeMs: z.number().optional(), + cost: z.number().optional(), + tokens: z.number().optional(), + }) + .optional(), + guesses: z.array( + z.object({ + participantId: z.string(), + participantType: z.enum(["human", "llm"]), + guess: z.string(), + semanticScore: z.number(), + timeBonus: z.number(), + finalScore: z.number(), + timeMs: z.number(), + cost: z.number().optional(), + tokens: z.number().optional(), + }), + ), +}); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const data = SaveRoundSchema.parse(body); + const identity = await getCurrentIdentity(request); + + const maxPossibleScore = data.guesserCount * MAX_SCORE_PER_GUESSER; + const topScore = Math.max(...data.guesses.map((g) => g.finalScore), 0); + + const [round] = await db + .insert(roundResults) + .values({ + sessionId: data.sessionId, + roundNumber: data.roundNumber, + drawerId: data.drawerId, + drawerType: data.drawerType, + prompt: data.prompt, + maxPossibleScore, + topScore, + svg: data.svg || data.imageDataUrl || data.drawing?.svg, + }) + .returning(); + + if (data.drawing && data.drawerType === "llm") { + await db.insert(drawings).values({ + sessionId: data.sessionId, + modelId: data.drawing.modelId, + svg: data.drawing.svg, + generationTimeMs: data.drawing.generationTimeMs, + cost: data.drawing.cost, + tokens: data.drawing.tokens, + }); + } + + if (data.guesses.length > 0) { + await db.insert(guesses).values( + data.guesses.map((g) => ({ + sessionId: data.sessionId, + roundId: round.id, + modelId: g.participantId, + guess: g.guess, + isCorrect: g.semanticScore > 0.7, + semanticScore: g.semanticScore, + timeBonus: g.timeBonus, + finalScore: g.finalScore, + isHuman: g.participantType === "human", + generationTimeMs: g.timeMs, + cost: g.cost, + tokens: g.tokens, + })), + ); + } + + for (const guess of data.guesses) { + if (guess.participantType === "llm") { + await db + .insert(modelScores) + .values({ + modelId: guess.participantId, + guessingScore: guess.semanticScore, + guessingRounds: 1, + totalCost: guess.cost || 0, + totalTokens: guess.tokens || 0, + }) + .onConflictDoUpdate({ + target: modelScores.modelId, + set: { + guessingScore: sql`${modelScores.guessingScore} + ${guess.semanticScore}`, + guessingRounds: sql`${modelScores.guessingRounds} + 1`, + totalCost: sql`${modelScores.totalCost} + ${guess.cost || 0}`, + totalTokens: sql`${modelScores.totalTokens} + ${guess.tokens || 0}`, + updatedAt: new Date(), + }, + }); + } else if (guess.participantType === "human") { + if (identity.clerkUserId) { + await db + .insert(playerScores) + .values({ + clerkUserId: identity.clerkUserId, + anonId: null, + username: identity.username, + totalScore: guess.finalScore, + guessingScore: guess.semanticScore, + guessingRounds: 1, + roundsPlayed: 1, + bestRoundScore: guess.finalScore, + }) + .onConflictDoUpdate({ + target: playerScores.clerkUserId, + set: { + username: identity.username, + totalScore: sql`${playerScores.totalScore} + ${guess.finalScore}`, + guessingScore: sql`${playerScores.guessingScore} + ${guess.semanticScore}`, + guessingRounds: sql`${playerScores.guessingRounds} + 1`, + roundsPlayed: sql`${playerScores.roundsPlayed} + 1`, + bestRoundScore: sql`GREATEST(${playerScores.bestRoundScore}, ${guess.finalScore})`, + updatedAt: new Date(), + }, + }); + } else if (identity.anonId) { + await db + .insert(playerScores) + .values({ + clerkUserId: null, + anonId: identity.anonId, + totalScore: guess.finalScore, + guessingScore: guess.semanticScore, + guessingRounds: 1, + roundsPlayed: 1, + bestRoundScore: guess.finalScore, + }) + .onConflictDoUpdate({ + target: playerScores.anonId, + set: { + totalScore: sql`${playerScores.totalScore} + ${guess.finalScore}`, + guessingScore: sql`${playerScores.guessingScore} + ${guess.semanticScore}`, + guessingRounds: sql`${playerScores.guessingRounds} + 1`, + roundsPlayed: sql`${playerScores.roundsPlayed} + 1`, + bestRoundScore: sql`GREATEST(${playerScores.bestRoundScore}, ${guess.finalScore})`, + updatedAt: new Date(), + }, + }); + } + } + } + + if (data.drawerType === "llm" && data.drawing) { + const drawerBonus = data.guesses.reduce((sum, g) => { + const multiplier = g.participantType === "human" ? 1.5 : 1; + return sum + (g.semanticScore > 0.7 ? 10 * multiplier : 0); + }, 0); + + await db + .insert(modelScores) + .values({ + modelId: data.drawing.modelId, + drawingScore: drawerBonus, + drawingRounds: 1, + totalCost: data.drawing.cost || 0, + totalTokens: data.drawing.tokens || 0, + }) + .onConflictDoUpdate({ + target: modelScores.modelId, + set: { + drawingScore: sql`${modelScores.drawingScore} + ${drawerBonus}`, + drawingRounds: sql`${modelScores.drawingRounds} + 1`, + totalCost: sql`${modelScores.totalCost} + ${data.drawing.cost || 0}`, + totalTokens: sql`${modelScores.totalTokens} + ${data.drawing.tokens || 0}`, + updatedAt: new Date(), + }, + }); + } else if (data.drawerType === "human") { + const drawerBonus = data.guesses.reduce((sum, g) => { + return sum + (g.semanticScore > 0.7 ? 10 : 0); + }, 0); + + if (identity.clerkUserId) { + await db + .insert(playerScores) + .values({ + clerkUserId: identity.clerkUserId, + anonId: null, + username: identity.username, + totalScore: drawerBonus, + drawingScore: drawerBonus, + drawingRounds: 1, + roundsPlayed: 1, + }) + .onConflictDoUpdate({ + target: playerScores.clerkUserId, + set: { + username: identity.username, + totalScore: sql`${playerScores.totalScore} + ${drawerBonus}`, + drawingScore: sql`${playerScores.drawingScore} + ${drawerBonus}`, + drawingRounds: sql`${playerScores.drawingRounds} + 1`, + roundsPlayed: sql`${playerScores.roundsPlayed} + 1`, + updatedAt: new Date(), + }, + }); + } else if (identity.anonId) { + await db + .insert(playerScores) + .values({ + clerkUserId: null, + anonId: identity.anonId, + totalScore: drawerBonus, + drawingScore: drawerBonus, + drawingRounds: 1, + roundsPlayed: 1, + }) + .onConflictDoUpdate({ + target: playerScores.anonId, + set: { + totalScore: sql`${playerScores.totalScore} + ${drawerBonus}`, + drawingScore: sql`${playerScores.drawingScore} + ${drawerBonus}`, + drawingRounds: sql`${playerScores.drawingRounds} + 1`, + roundsPlayed: sql`${playerScores.roundsPlayed} + 1`, + updatedAt: new Date(), + }, + }); + } + } + + return NextResponse.json( + { + id: round.id, + maxPossibleScore, + topScore, + }, + { status: 201 }, + ); + } catch (error) { + console.error("Error saving round:", error); + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data", details: error.issues }, + { status: 400 }, + ); + } + return NextResponse.json( + { error: "Failed to save round" }, + { status: 500 }, + ); + } +} diff --git a/app/api/score-guess/route.ts b/app/api/score-guess/route.ts new file mode 100644 index 0000000..dc53007 --- /dev/null +++ b/app/api/score-guess/route.ts @@ -0,0 +1,35 @@ +import { calculateFinalScore, calculateSemanticScore } from "@/lib/scoring"; + +export async function POST(request: Request) { + try { + const { guess, answer, timeMs, isHumanGuesser } = await request.json(); + + if (!guess || typeof guess !== "string") { + return Response.json({ error: "Guess is required" }, { status: 400 }); + } + + if (!answer || typeof answer !== "string") { + return Response.json({ error: "Answer is required" }, { status: 400 }); + } + + const semanticScore = await calculateSemanticScore(guess, answer); + const scoreBreakdown = calculateFinalScore( + semanticScore, + timeMs || 60000, + isHumanGuesser || false, + ); + + return Response.json({ + guess, + answer, + semanticScore, + ...scoreBreakdown, + }); + } catch (error) { + console.error("Error in score-guess:", error); + return Response.json( + { error: "Failed to calculate score" }, + { status: 500 }, + ); + } +} diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts new file mode 100644 index 0000000..32ec884 --- /dev/null +++ b/app/api/sessions/route.ts @@ -0,0 +1,50 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db } from "@/db"; +import { gameSessions } from "@/db/schema"; +import { getCurrentIdentity } from "@/lib/identity"; + +const CreateSessionSchema = z.object({ + mode: z.enum(["pictionary", "human_judge", "model_guess", "ai_duel"]), + prompt: z.string(), + totalRounds: z.number(), + participants: z.array(z.string()), +}); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const data = CreateSessionSchema.parse(body); + const identity = await getCurrentIdentity(request); + + const [session] = await db + .insert(gameSessions) + .values({ + mode: data.mode, + prompt: data.prompt, + totalRounds: data.totalRounds, + currentRound: 1, + anonId: identity.anonId || null, + clerkUserId: identity.clerkUserId || null, + }) + .returning(); + + if (!session) { + throw new Error("Failed to create session"); + } + + return NextResponse.json({ sessionId: session.id }, { status: 201 }); + } catch (error) { + console.error("Error creating session:", error); + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data", details: error.issues }, + { status: 400 }, + ); + } + return NextResponse.json( + { error: "Failed to create session" }, + { status: 500 }, + ); + } +} diff --git a/app/api/webhooks/clerk/route.ts b/app/api/webhooks/clerk/route.ts index 8e8a7da..d62648f 100644 --- a/app/api/webhooks/clerk/route.ts +++ b/app/api/webhooks/clerk/route.ts @@ -2,7 +2,7 @@ import { and, eq, isNull } from "drizzle-orm"; import { headers } from "next/headers"; import { type NextRequest, NextResponse } from "next/server"; import { db } from "@/db"; -import { gameSessions } from "@/db/schema"; +import { gameSessions, playerScores } from "@/db/schema"; const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET; @@ -53,35 +53,70 @@ export async function POST(request: NextRequest) { const eventType = evt.type; - if (eventType === "user.created") { - const { id: clerkUserId, public_metadata } = evt.data; - - const anonId = public_metadata?.anonId as string | undefined; - - if (anonId) { - try { - await db - .update(gameSessions) - .set({ - clerkUserId, - anonId: null, - }) - .where( - and( - eq(gameSessions.anonId, anonId), - isNull(gameSessions.clerkUserId), - ), - ); + if (eventType === "user.created" || eventType === "user.updated") { + const { + id: clerkUserId, + public_metadata, + username, + first_name, + last_name, + } = evt.data; + + const displayName = + username || [first_name, last_name].filter(Boolean).join(" ") || null; + + try { + await db + .update(playerScores) + .set({ username: displayName, updatedAt: new Date() }) + .where(eq(playerScores.clerkUserId, clerkUserId)); + } catch (error) { + console.error("Error updating username:", error); + } - return NextResponse.json({ success: true }); - } catch (error) { - console.error("Error merging sessions:", error); - return NextResponse.json( - { error: "Failed to merge sessions" }, - { status: 500 }, - ); + if (eventType === "user.created") { + const anonId = public_metadata?.anonId as string | undefined; + + if (anonId) { + try { + await db + .update(gameSessions) + .set({ + clerkUserId, + anonId: null, + }) + .where( + and( + eq(gameSessions.anonId, anonId), + isNull(gameSessions.clerkUserId), + ), + ); + + await db + .update(playerScores) + .set({ + clerkUserId, + anonId: null, + username: displayName, + updatedAt: new Date(), + }) + .where( + and( + eq(playerScores.anonId, anonId), + isNull(playerScores.clerkUserId), + ), + ); + } catch (error) { + console.error("Error merging sessions:", error); + return NextResponse.json( + { error: "Failed to merge sessions" }, + { status: 500 }, + ); + } } } + + return NextResponse.json({ success: true }); } return NextResponse.json({ received: true }); diff --git a/app/gallery/[id]/page.tsx b/app/gallery/[id]/page.tsx index 4626938..b03e732 100644 --- a/app/gallery/[id]/page.tsx +++ b/app/gallery/[id]/page.tsx @@ -9,8 +9,11 @@ import { DollarSign, Eye, Image, + Pencil, Trophy, + User, X, + Zap, } from "lucide-react"; import Link from "next/link"; import { use } from "react"; @@ -19,7 +22,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -import { useSessionDetail } from "@/lib/hooks/use-gallery"; +import { type GameRound, useSessionDetail } from "@/lib/hooks/use-gallery"; import { formatCost, getModelById } from "@/lib/models"; import { cn } from "@/lib/utils"; @@ -32,12 +35,14 @@ export default function GalleryDetailPage({ const { data: item, isLoading, error } = useSessionDetail(id); const modeIcons = { + pictionary: Zap, human_judge: Eye, model_guess: Image, ai_duel: Bot, }; const modeLabels = { + pictionary: "Pictionary", human_judge: "Human Judge", model_guess: "Model Guess", ai_duel: "AI Duel", @@ -90,6 +95,7 @@ export default function GalleryDetailPage({

Session Details

{modeLabels[item.mode]} + {item.playerName && ` • ${item.playerName}`}

@@ -224,78 +230,113 @@ export default function GalleryDetailPage({
)} - {item.mode === "ai_duel" && ( + {(item.mode === "ai_duel" || item.mode === "pictionary") && (
-

Drawings & Guesses

-
- {item.drawings.map((drawing) => { - const model = getModelById(drawing.modelId); - const hasReplay = - drawing.chunks && drawing.chunks.length > 0; - const relatedGuesses = item.guesses.filter( - (g) => g.modelId !== drawing.modelId, - ); - return ( - - -
-
- - {model?.name} - - - Drawer - -
+

Rounds

+ {item.rounds && item.rounds.length > 0 ? ( +
+ {item.rounds.map((round) => ( + + ))} +
+ ) : ( +
+ {item.drawings.map((drawing) => { + const model = getModelById(drawing.modelId); + const hasReplay = + drawing.chunks && drawing.chunks.length > 0; + const relatedGuesses = item.guesses.filter( + (g) => g.modelId !== drawing.modelId, + ); + const isHumanDrawing = drawing.modelId === "human"; + const isImageDataUrl = + drawing.svg?.startsWith("data:image"); + return ( + + +
+ {isHumanDrawing ? ( + + ) : ( +
+ )} + + {isHumanDrawing + ? item.playerName || "Player" + : model?.name} + + + + Drawer + +
- + {isImageDataUrl ? ( +
+ Drawing +
+ ) : ( + + )} - {relatedGuesses.length > 0 && ( -
-

- Guesses: -

- {relatedGuesses.map((guess) => { - const guessModel = getModelById(guess.modelId); - return ( -
+ {relatedGuesses.length > 0 && ( +
+

+ Guesses: +

+ {relatedGuesses.map((guess) => { + const guessModel = getModelById( + guess.modelId, + ); + return (
- - {guessModel?.name}: “{guess.guess} - ” - - {guess.isCorrect && ( - - )} -
- ); - })} -
- )} - - - ); - })} -
+ key={guess.id} + className={cn( + "flex items-center gap-1.5 p-1.5 rounded text-xs", + guess.isCorrect && "bg-green-500/10", + )} + > +
+ + {guessModel?.name || guess.modelId}: + “{guess.guess} + ” + + {guess.isCorrect && ( + + )} +
+ ); + })} +
+ )} + + + ); + })} +
+ )}
)} @@ -304,3 +345,146 @@ export default function GalleryDetailPage({ ); } + +function RoundCard({ + round, + playerName, +}: { + round: GameRound; + playerName?: string | null; +}) { + const isHumanDrawer = round.drawerType === "human"; + const drawerModel = !isHumanDrawer ? getModelById(round.drawerId) : null; + const hasReplay = round.chunks && round.chunks.length > 0; + const isImageDataUrl = round.svg?.startsWith("data:image"); + + return ( + + +
+
+ + Round {round.roundNumber} + + + “{round.prompt}” + +
+
+ {isHumanDrawer ? ( + <> + + + {playerName || "Player"} + + + + Drawer + + + ) : ( + <> +
+ + {drawerModel?.name} + + + + Drawer + + + )} +
+
+ +
+
+ {round.svg ? ( + isImageDataUrl ? ( +
+ {`Drawing +
+ ) : ( + + ) + ) : ( +
+ +
+ )} +
+ +
+

+ Guesses ({round.guesses.length}) +

+
+ {round.guesses.length > 0 ? ( + round.guesses.map((guess) => { + const guessModel = !guess.isHuman + ? getModelById(guess.modelId) + : null; + return ( +
+ {guess.isHuman ? ( + + ) : ( +
+ )} +
+
+ + {guess.isHuman + ? playerName || "Player" + : guessModel?.name || guess.modelId} + + {guess.isCorrect && ( + + )} +
+

+ “{guess.guess}” +

+ {guess.semanticScore !== null && ( +

+ Score: {Math.round(guess.semanticScore * 100)}% + {guess.finalScore !== null && + ` • ${guess.finalScore} pts`} +

+ )} +
+
+ ); + }) + ) : ( +

No guesses

+ )} +
+
+
+ + + ); +} diff --git a/app/gallery/page.tsx b/app/gallery/page.tsx index 5ba6168..612d4fa 100644 --- a/app/gallery/page.tsx +++ b/app/gallery/page.tsx @@ -3,43 +3,45 @@ import { formatDistanceToNow } from "date-fns"; import { ArrowLeft, - Bot, ChevronLeft, ChevronRight, Clock, DollarSign, - Eye, Image, + LayoutGrid, + List, + Search, Trophy, + User, + Zap, } from "lucide-react"; import Link from "next/link"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - type GalleryItem, - type GameMode, - useGallery, -} from "@/lib/hooks/use-gallery"; +import { type GalleryItem, useGallery } from "@/lib/hooks/use-gallery"; import { formatCost, getModelById } from "@/lib/models"; +import { cn } from "@/lib/utils"; export default function GalleryPage() { - const [mode, setMode] = useState(undefined); const [page, setPage] = useState(1); + const [searchQuery, setSearchQuery] = useState(""); + const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const pageSize = 20; - const { data, isLoading, error } = useGallery(mode, page, pageSize); - - const handleModeChange = (value: string) => { - setMode(value === "all" ? undefined : (value as GameMode)); - setPage(1); - }; + const { data, isLoading, error } = useGallery("pictionary", page, pageSize); const totalPages = data ? Math.ceil(data.total / pageSize) : 0; + const filteredItems = data?.items.filter((item) => + searchQuery + ? item.prompt.toLowerCase().includes(searchQuery.toLowerCase()) + : true, + ); + return (
@@ -51,33 +53,115 @@ export default function GalleryPage() {
-

Gallery

+

Game History

- Explore all game results + Browse past games and results

- - - All - Human Judge - Model Guess - AI Duel - - + {data && data.total > 0 && ( +
+ + +
{data.total}
+
Total Games
+
+
+ + +
+ {data.items.reduce((sum, i) => sum + i.drawings.length, 0)} +
+
+ Drawings Created +
+
+
+ + +
+ {data.items.reduce((sum, i) => sum + i.guesses.length, 0)} +
+
+ Guesses Made +
+
+
+ + +
+ {formatCost( + data.items.reduce((sum, i) => sum + i.totalCost, 0), + )} +
+
Total Cost
+
+
+
+ )} + +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-9 w-64" + /> +
+
+ + +
+
+
{isLoading && ( -
+
{[...Array(6)].map((_, i) => ( - -
- - -
+ {viewMode === "grid" ? ( + <> + +
+ + +
+ + ) : ( +
+ +
+ + +
+
+ )}
))} @@ -87,30 +171,50 @@ export default function GalleryPage() { {error && ( - Failed to load gallery. Please try again. + Failed to load game history. Please try again. )} - {data && data.items.length === 0 && ( + {data && filteredItems && filteredItems.length === 0 && ( -

No results yet

+

+ {searchQuery ? "No games match your search" : "No games yet"} +

- Play some games to see results here! + {searchQuery + ? "Try a different search term" + : "Play your first game to see it here!"}

+ {!searchQuery && ( + + )}
)} - {data && data.items.length > 0 && ( + {data && filteredItems && filteredItems.length > 0 && ( <> -
- {data.items.map((item) => ( - - ))} -
+ {viewMode === "grid" ? ( +
+ {filteredItems.map((item) => ( + + ))} +
+ ) : ( +
+ {filteredItems.map((item) => ( + + ))} +
+ )} {totalPages > 1 && (
@@ -145,91 +249,170 @@ export default function GalleryPage() { } function GalleryCard({ item }: { item: GalleryItem }) { - const modeIcons: Record = { - human_judge: Eye, - model_guess: Image, - ai_duel: Bot, - }; + const [currentDrawingIndex, setCurrentDrawingIndex] = useState(0); + const [currentChunkIndex, setCurrentChunkIndex] = useState(0); + const intervalRef = useRef(null); + + const currentDrawing = item.drawings[currentDrawingIndex]; + const chunks = currentDrawing?.chunks || []; + const hasReplay = chunks.length > 0; + + const allFrames = hasReplay + ? [...chunks, currentDrawing?.svg || ""] + : [currentDrawing?.svg || ""]; + + const currentSvg = allFrames[currentChunkIndex] || currentDrawing?.svg || ""; - const modeLabels: Record = { - human_judge: "Human Judge", - model_guess: "Model Guess", - ai_duel: "AI Duel", - }; + useEffect(() => { + if (!hasReplay) return; - const Icon = modeIcons[item.mode]; + intervalRef.current = setInterval(() => { + setCurrentChunkIndex((prev) => { + if (prev >= allFrames.length - 1) { + setTimeout(() => { + setCurrentDrawingIndex((di) => (di + 1) % item.drawings.length); + setCurrentChunkIndex(0); + }, 1000); + return prev; + } + return prev + 1; + }); + }, 60); - const winnerDrawing = item.drawings.find((d: any) => d.isWinner); - const displayDrawing = winnerDrawing || item.drawings[0]; + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [hasReplay, allFrames.length, item.drawings.length, currentDrawingIndex]); + + useEffect(() => { + setCurrentChunkIndex(0); + }, [currentDrawingIndex]); + + const winnerDrawing = item.drawings.find((d) => d.isWinner); + const correctGuesses = item.guesses.filter((g) => g.isCorrect).length; + const isImageDataUrl = currentSvg.startsWith("data:image"); + const isHumanDrawing = currentDrawing?.isHumanDrawing; return ( - +
- {displayDrawing ? ( -
+ {currentDrawing ? ( + isImageDataUrl ? ( + {item.prompt} + ) : ( +
+ ) ) : (
)} -
- - - {modeLabels[item.mode]} - -
+ {item.drawings.length > 1 && ( +
+ {item.drawings.map((_, idx) => ( +
+ ))} +
+ )} {winnerDrawing && (
- Winner
)} + {hasReplay && ( +
+ + {currentChunkIndex + 1}/{allFrames.length} +
+ )} +
+

+ “{item.prompt}” +

+
-
-

- “{item.prompt}” -

-
+
+
+ {item.playerName && ( + <> + + {item.playerName} + + )} {formatDistanceToNow(new Date(item.createdAt), { addSuffix: true, })}
-
- - {formatCost(item.totalCost)} +
+ {correctGuesses > 0 && ( + + {correctGuesses} correct + + )} +
+ + {formatCost(item.totalCost)} +
- {item.drawings.slice(0, 3).map((drawing: any) => { + {item.drawings.slice(0, 4).map((drawing, idx) => { const model = getModelById(drawing.modelId); - if (!model) return null; - return ( - -
- {model.name} - + +
+ ) : ( +
); })} - {item.drawings.length > 3 && ( - - +{item.drawings.length - 3} more - + {item.drawings.length > 4 && ( + + +{item.drawings.length - 4} + )}
@@ -238,3 +421,168 @@ function GalleryCard({ item }: { item: GalleryItem }) { ); } + +function GalleryListItem({ item }: { item: GalleryItem }) { + const [currentDrawingIndex, setCurrentDrawingIndex] = useState(0); + const [currentChunkIndex, setCurrentChunkIndex] = useState(0); + const intervalRef = useRef(null); + + const currentDrawing = item.drawings[currentDrawingIndex]; + const chunks = currentDrawing?.chunks || []; + const hasReplay = chunks.length > 0; + + const allFrames = hasReplay + ? [...chunks, currentDrawing?.svg || ""] + : [currentDrawing?.svg || ""]; + + const currentSvg = allFrames[currentChunkIndex] || currentDrawing?.svg || ""; + + useEffect(() => { + if (!hasReplay) return; + + intervalRef.current = setInterval(() => { + setCurrentChunkIndex((prev) => { + if (prev >= allFrames.length - 1) { + setTimeout(() => { + setCurrentDrawingIndex((di) => (di + 1) % item.drawings.length); + setCurrentChunkIndex(0); + }, 800); + return prev; + } + return prev + 1; + }); + }, 50); + + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [hasReplay, allFrames.length, item.drawings.length, currentDrawingIndex]); + + useEffect(() => { + setCurrentChunkIndex(0); + }, [currentDrawingIndex]); + + const correctGuesses = item.guesses.filter((g) => g.isCorrect).length; + const isImageDataUrl = currentSvg.startsWith("data:image"); + + return ( + + + +
+
+ {currentDrawing ? ( + isImageDataUrl ? ( + {item.prompt} + ) : ( +
+ ) + ) : ( +
+ +
+ )} + {item.drawings.length > 1 && ( +
+ {item.drawings.map((_, idx) => ( +
+ ))} +
+ )} +
+
+
+
+

+ “{item.prompt}” +

+
+ {item.drawings.length} drawings + + {item.guesses.length} guesses + {correctGuesses > 0 && ( + <> + + + {correctGuesses} correct + + + )} +
+
+
+ {item.playerName && ( +
+ + {item.playerName} +
+ )} +
+ {formatDistanceToNow(new Date(item.createdAt), { + addSuffix: true, + })} +
+
{formatCost(item.totalCost)}
+
+
+
+ {item.drawings.slice(0, 6).map((drawing, idx) => { + const model = getModelById(drawing.modelId); + const isHuman = + drawing.isHumanDrawing || drawing.modelId === "human"; + if (!model && !isHuman) return null; + return isHuman ? ( +
+ +
+ ) : ( +
+ ); + })} + {item.drawings.length > 6 && ( + + +{item.drawings.length - 6} + + )} +
+
+
+ + + + ); +} diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index f739c8b..610fe8c 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -5,8 +5,13 @@ import { ArrowLeft, ArrowUp, ArrowUpDown, + Bot, + Eye, + Palette, + Target, TrendingUp, Trophy, + User, } from "lucide-react"; import Link from "next/link"; import { useMemo, useState } from "react"; @@ -21,53 +26,60 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { useLeaderboard } from "@/lib/hooks/use-leaderboard"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + type LeaderboardEntry, + type LeaderboardType, + useLeaderboard, +} from "@/lib/hooks/use-leaderboard"; import { AVAILABLE_MODELS, formatCost, getModelById } from "@/lib/models"; import { cn } from "@/lib/utils"; type SortField = | "overallScore" - | "humanJudgeWinRate" - | "modelGuessAccuracy" - | "aiDuelPoints" + | "drawingScore" + | "guessingScore" | "totalCost" - | "totalTokens"; + | "roundsPlayed"; export default function LeaderboardPage() { - const { data, isLoading, error } = useLeaderboard(); + const [leaderboardType, setLeaderboardType] = + useState("combined"); + const { data, isLoading, error } = useLeaderboard(leaderboardType); const [sortField, setSortField] = useState("overallScore"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); const [providerFilter, setProviderFilter] = useState("all"); - const [tierFilter, setTierFilter] = useState("all"); const providers = useMemo(() => { const uniqueProviders = new Set(AVAILABLE_MODELS.map((m) => m.provider)); return Array.from(uniqueProviders); }, []); - const sortedModels = useMemo(() => { - if (!data?.models) return []; + const sortedEntries = useMemo(() => { + if (!data?.entries) return []; - const filtered = data.models.filter((m) => { - const model = getModelById(m.modelId); - if (!model) return false; - const matchesProvider = - providerFilter === "all" || model.provider === providerFilter; - const matchesTier = tierFilter === "all" || model.tier === tierFilter; - return matchesProvider && matchesTier; - }); + let filtered = data.entries; - filtered.sort((a, b) => { - const aValue = a[sortField]; - const bValue = b[sortField]; - if (sortDirection === "asc") { - return aValue - bValue; - } - return bValue - aValue; - }); + if (leaderboardType === "llm" && providerFilter !== "all") { + filtered = filtered.filter((entry) => { + if (entry.type !== "llm" || !entry.modelId) return false; + const model = getModelById(entry.modelId); + return model?.provider === providerFilter; + }); + } - return filtered; - }, [data?.models, sortField, sortDirection, providerFilter, tierFilter]); + return [...filtered].sort((a, b) => { + const aValue = a[sortField] ?? 0; + const bValue = b[sortField] ?? 0; + return sortDirection === "asc" ? aValue - bValue : bValue - aValue; + }); + }, [ + data?.entries, + sortField, + sortDirection, + providerFilter, + leaderboardType, + ]); const handleSort = (field: SortField) => { if (sortField === field) { @@ -102,12 +114,32 @@ export default function LeaderboardPage() {

Leaderboard

- Model performance rankings + Top performers in AI Pictionary

+ setLeaderboardType(v as LeaderboardType)} + > + + + + All + + + + AI Models + + + + Players + + + + {isLoading && ( @@ -131,37 +163,27 @@ export default function LeaderboardPage() { {data && ( <>
-
- Provider: - -
-
- Tier: - -
+ {leaderboardType === "llm" && ( +
+ + Provider: + + +
+ )}
- {data.totalSessions} total sessions + {data.totalSessions} total games played
@@ -171,188 +193,84 @@ export default function LeaderboardPage() { Rank - Model + + {leaderboardType === "human" ? "Player" : "Competitor"} + handleSort("overallScore")} >
- Overall Score + + Score
handleSort("humanJudgeWinRate")} - > -
- Human Judge Win Rate - -
-
- handleSort("modelGuessAccuracy")} - > -
- Model Guess Accuracy - -
-
- handleSort("aiDuelPoints")} + onClick={() => handleSort("drawingScore")} >
- AI Duel Points - + + Drawing +
handleSort("totalCost")} + onClick={() => handleSort("guessingScore")} >
- Total Cost - + + Guessing +
handleSort("totalTokens")} + onClick={() => handleSort("roundsPlayed")} >
- Total Tokens - + Rounds +
+ {leaderboardType !== "human" && ( + handleSort("totalCost")} + > +
+ Cost + +
+
+ )}
- {sortedModels.length === 0 ? ( + {sortedEntries.length === 0 ? ( - No models found matching filters + {leaderboardType === "human" + ? "No players yet. Be the first to play!" + : "No entries found"} ) : ( - sortedModels.map((model, index) => { - const modelInfo = getModelById(model.modelId); - if (!modelInfo) return null; - - const isTopThree = - index < 3 && - sortField === "overallScore" && - sortDirection === "desc"; - - return ( - - -
- {isTopThree && index === 0 && ( - - )} - - {index + 1} - -
-
- -
-
-
-
- {modelInfo.name} -
-
- {modelInfo.provider} -
-
-
- - -
- - {model.overallScore.toFixed(1)} - - {isTopThree && index === 0 && ( - - )} -
-
- - {model.humanJudgePlays > 0 ? ( -
-
- {model.humanJudgeWinRate.toFixed(1)}% -
-
- {model.humanJudgeWins}/ - {model.humanJudgePlays} wins -
-
- ) : ( - - )} -
- - {model.modelGuessTotal > 0 ? ( -
-
- {model.modelGuessAccuracy.toFixed(1)}% -
-
- {model.modelGuessCorrect}/ - {model.modelGuessTotal} correct -
-
- ) : ( - - )} -
- - {model.aiDuelRounds > 0 ? ( -
-
- {model.aiDuelPoints} -
-
- {model.aiDuelRounds} rounds -
-
- ) : ( - - )} -
- - - {formatCost(model.totalCost)} - - - - - {model.totalTokens.toLocaleString()} - - - - ); - }) + sortedEntries.map((entry, index) => ( + + )) )} @@ -364,3 +282,113 @@ export default function LeaderboardPage() {
); } + +function LeaderboardRow({ + entry, + index, + sortField, + sortDirection, + showCost, +}: { + entry: LeaderboardEntry; + index: number; + sortField: SortField; + sortDirection: "asc" | "desc"; + showCost: boolean; +}) { + const isTopThree = + index < 3 && sortField === "overallScore" && sortDirection === "desc"; + + const modelInfo = + entry.type === "llm" && entry.modelId ? getModelById(entry.modelId) : null; + const color = modelInfo?.color || "#10b981"; + + return ( + + +
+ {isTopThree && index === 0 && ( + + )} + + {index + 1} + +
+
+ +
+
+
+
+ {entry.name} + {entry.type === "llm" ? ( + + ) : ( + + )} +
+ {entry.type === "llm" && modelInfo && ( +
+ {modelInfo.provider} +
+ )} +
+
+ + +
+ + {entry.overallScore.toFixed(1)} + + {isTopThree && index === 0 && ( + + )} +
+
+ + {entry.drawingRounds > 0 ? ( +
+
+ {entry.drawingScore.toFixed(2)} +
+
+ {entry.drawingRounds} rounds +
+
+ ) : ( + + )} +
+ + {entry.guessingRounds > 0 ? ( +
+
+ {entry.guessingScore.toFixed(2)} +
+
+ {entry.guessingRounds} rounds +
+
+ ) : ( + + )} +
+ + {entry.roundsPlayed} + + {showCost && ( + + {formatCost(entry.totalCost)} + + )} + + ); +} diff --git a/app/page.tsx b/app/page.tsx index 7225947..be521d1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,101 +1,71 @@ -import { Brush, MessageCircle, Sparkles } from "lucide-react"; +import { Image, Trophy, Zap } from "lucide-react"; import Link from "next/link"; -import { GameModeCard } from "@/components/game-mode-card"; -import { GithubLogoThemeAware } from "@/components/logos/github-theme-aware"; +import { GameExamples } from "@/components/game-examples"; +import { InlineLeaderboard } from "@/components/inline-leaderboard"; import { PintelLogoThemeAware } from "@/components/logos/pintel-theme-aware"; import { SectionSeparator } from "@/components/section-separator"; import { Button } from "@/components/ui/button"; import { contentContainer } from "@/lib/grid-patterns"; -const gameModes = [ - { - id: "human-judge", - title: "Human Judge", - description: "AI models draw. You decide which captures the concept best.", - icon: Brush, - href: "/play/human-judge", - available: true, - }, - { - id: "model-guess", - title: "Model Guess", - description: "You draw. AI models compete to guess what it is.", - icon: MessageCircle, - href: "/play/model-guess", - available: true, - }, - { - id: "ai-duel", - title: "AI Duel", - description: "Watch AI models draw and guess each other's creations.", - icon: Sparkles, - href: "/play/ai-duel", - available: true, - }, -]; - export default function Home() { return (
-
+
- + +
+
+

+ Draw. Guess. Compete. +

+

+ Challenge AI models in Pictionary. Take turns drawing and guessing + to see who truly understands visual communication. +

-

- draw • guess • evaluate -

+
+ +
+ -

- A multimodal game where humans and AI models try to understand each - other's drawings. -

+ -
- {gameModes.map((mode) => ( - - ))} -
+ + +
+
+ + Top Performers +
+ +
- -
); } diff --git a/app/play/ai-duel/page.tsx b/app/play/ai-duel/page.tsx deleted file mode 100644 index b66d4c5..0000000 --- a/app/play/ai-duel/page.tsx +++ /dev/null @@ -1,1078 +0,0 @@ -"use client"; - -import { - ArrowLeft, - Bot, - Check, - Clock, - Coins, - DollarSign, - Eye, - FastForward, - Pause, - Pencil, - Play, - RotateCcw, - Shuffle, - Trophy, - X, - Zap, -} from "lucide-react"; -import Link from "next/link"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { SignupPrompt } from "@/components/signup-prompt"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Progress } from "@/components/ui/progress"; -import { Spinner } from "@/components/ui/spinner"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useSaveSession } from "@/lib/hooks/use-gallery"; -import { useUserIdentity } from "@/lib/hooks/use-user-identity"; -import { - DEFAULT_VISION_MODELS, - formatCost, - getModelById, - getVisionModels, - shuffleModels, -} from "@/lib/models"; -import { getRandomPrompt } from "@/lib/prompts"; -import { cn } from "@/lib/utils"; - -interface DuelState { - status: "setup" | "running" | "paused" | "round-end" | "finished"; - selectedModels: string[]; - currentRound: number; - totalRounds: number; - currentDrawer: string | null; - currentPrompt: string; - currentSvg: string | null; - guesses: Record< - string, - { guess: string; isCorrect: boolean; timeMs: number } - >; - leaderboard: Record< - string, - { draws: number; correctGuesses: number; points: number } - >; - totalCost: number; - totalTokens: number; - roundHistory: RoundResult[]; - autoPlay: boolean; - speed: "normal" | "fast" | "instant"; -} - -interface RoundResult { - drawer: string; - prompt: string; - svg: string; - guesses: Record; - winner: string | null; -} - -export default function AIDuelPage() { - const saveSession = useSaveSession(); - const { isAuthenticated } = useUserIdentity(); - const [showSignupPrompt, setShowSignupPrompt] = useState(false); - const [state, setState] = useState({ - status: "setup", - selectedModels: DEFAULT_VISION_MODELS, - currentRound: 0, - totalRounds: 8, - currentDrawer: null, - currentPrompt: "", - currentSvg: null, - guesses: {}, - leaderboard: {}, - totalCost: 0, - totalTokens: 0, - roundHistory: [], - autoPlay: true, - speed: "normal", - }); - - const [phaseStatus, setPhaseStatus] = useState< - "idle" | "drawing" | "guessing" | "scoring" - >("idle"); - const [elapsedTime, setElapsedTime] = useState(0); - const startTimeRef = useRef(null); - const autoPlayRef = useRef(null); - - const visionModels = useMemo(() => getVisionModels(), []); - - // Timer - useEffect(() => { - if (phaseStatus !== "idle") { - startTimeRef.current = Date.now(); - const interval = setInterval(() => { - if (startTimeRef.current) { - setElapsedTime( - Math.floor((Date.now() - startTimeRef.current) / 1000), - ); - } - }, 1000); - return () => clearInterval(interval); - } else { - setElapsedTime(0); - startTimeRef.current = null; - } - }, [phaseStatus]); - - // Cleanup autoplay on unmount - useEffect(() => { - return () => { - if (autoPlayRef.current) { - clearTimeout(autoPlayRef.current); - } - }; - }, []); - - const toggleModel = useCallback((modelId: string) => { - setState((prev) => ({ - ...prev, - selectedModels: prev.selectedModels.includes(modelId) - ? prev.selectedModels.filter((id) => id !== modelId) - : prev.selectedModels.length < 6 - ? [...prev.selectedModels, modelId] - : prev.selectedModels, - })); - }, []); - - const shuffleSelection = useCallback(() => { - const shuffled = shuffleModels(visionModels, 4); - setState((prev) => ({ - ...prev, - selectedModels: shuffled.map((m) => m.id), - })); - }, [visionModels]); - - // Convert SVG to PNG data URL for vision models - const svgToPng = useCallback((svgString: string): Promise => { - return new Promise((resolve, reject) => { - const canvas = document.createElement("canvas"); - canvas.width = 400; - canvas.height = 400; - const ctx = canvas.getContext("2d"); - if (!ctx) { - reject(new Error("Could not get canvas context")); - return; - } - - const img = new Image(); - const svgBlob = new Blob([svgString], { - type: "image/svg+xml;charset=utf-8", - }); - const url = URL.createObjectURL(svgBlob); - - img.onload = () => { - ctx.fillStyle = "white"; - ctx.fillRect(0, 0, 400, 400); - ctx.drawImage(img, 0, 0, 400, 400); - URL.revokeObjectURL(url); - resolve(canvas.toDataURL("image/png")); - }; - - img.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error("Failed to load SVG")); - }; - - img.src = url; - }); - }, []); - - const togglePause = useCallback(() => { - if (autoPlayRef.current) { - clearTimeout(autoPlayRef.current); - autoPlayRef.current = null; - } - setState((prev) => ({ - ...prev, - autoPlay: !prev.autoPlay, - })); - }, []); - - // Check if guess matches prompt (simple fuzzy matching) - const checkGuess = useCallback((guess: string, prompt: string): boolean => { - const normalizedGuess = guess.toLowerCase().trim(); - const normalizedPrompt = prompt.toLowerCase().trim(); - if ( - normalizedGuess.includes(normalizedPrompt) || - normalizedPrompt.includes(normalizedGuess) - ) { - return true; - } - const promptWords = normalizedPrompt.split(/\s+/); - const guessWords = normalizedGuess.split(/\s+/); - const significantWords = promptWords.filter((w) => w.length > 2); - return significantWords.some((word) => - guessWords.some((gw) => gw.includes(word) || word.includes(gw)), - ); - }, []); - - const runRound = useCallback( - async ( - drawerId: string, - models: string[], - leaderboard: DuelState["leaderboard"], - roundNum: number, - ) => { - const prompt = getRandomPrompt(); - const guessers = models.filter((id) => id !== drawerId); - - setState((prev) => ({ - ...prev, - currentDrawer: drawerId, - currentPrompt: prompt, - currentSvg: null, - guesses: {}, - })); - - setPhaseStatus("drawing"); - - // Phase 1: Drawing - try { - const drawResponse = await fetch("/api/generate-drawings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ prompt, models: [drawerId] }), - }); - - if (!drawResponse.ok) throw new Error("Drawing failed"); - - const reader = drawResponse.body?.getReader(); - if (!reader) throw new Error("No response body"); - - const decoder = new TextDecoder(); - let buffer = ""; - let finalSvg = ""; - let drawCost = 0; - let drawTokens = 0; - const drawChunks: string[] = []; // Track chunks for replay - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - try { - const event = JSON.parse(line.slice(6)); - if (event.type === "partial" && event.svg) { - drawChunks.push(event.svg); // Collect chunks for replay - setState((prev) => ({ ...prev, currentSvg: event.svg })); - } else if (event.type === "drawing") { - finalSvg = event.svg; - drawCost = event.cost || 0; - drawTokens = event.usage?.totalTokens || 0; - setState((prev) => ({ ...prev, currentSvg: event.svg })); - } - } catch {} - } - } - - if (!finalSvg) throw new Error("No SVG generated"); - - // Update drawer stats - const newLeaderboard = { ...leaderboard }; - newLeaderboard[drawerId].draws += 1; - - setState((prev) => ({ - ...prev, - leaderboard: newLeaderboard, - totalCost: prev.totalCost + drawCost, - totalTokens: prev.totalTokens + drawTokens, - })); - - // Phase 2: Guessing - setPhaseStatus("guessing"); - - // Convert SVG to PNG for vision models - console.log("[AI Duel] Converting SVG to PNG..."); - const pngDataUrl = await svgToPng(finalSvg); - console.log("[AI Duel] PNG ready, sending to vision models..."); - - const guessResponse = await fetch("/api/guess-drawing", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ imageDataUrl: pngDataUrl, models: guessers }), - }); - - if (!guessResponse.ok) throw new Error("Guessing failed"); - - const guessReader = guessResponse.body?.getReader(); - if (!guessReader) throw new Error("No response body"); - - let guessBuffer = ""; - const roundGuesses: Record< - string, - { - guess: string; - isCorrect: boolean; - timeMs: number; - cost?: number; - tokens?: number; - } - > = {}; - let guessCost = 0; - let guessTokens = 0; - - while (true) { - const { done, value } = await guessReader.read(); - if (done) break; - - guessBuffer += decoder.decode(value, { stream: true }); - const lines = guessBuffer.split("\n\n"); - guessBuffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - try { - const event = JSON.parse(line.slice(6)); - console.log( - "[AI Duel] Guess event:", - event.type, - event.modelId, - event.guess?.substring(0, 50), - ); - - if (event.type === "partial") { - setState((prev) => ({ - ...prev, - guesses: { - ...prev.guesses, - [event.modelId]: { - guess: event.guess, - isCorrect: false, - timeMs: 0, - }, - }, - })); - } else if (event.type === "guess") { - const isCorrect = checkGuess(event.guess, prompt); - console.log( - "[AI Duel] Final guess:", - event.modelId, - "->", - event.guess, - "correct:", - isCorrect, - ); - roundGuesses[event.modelId] = { - guess: event.guess, - isCorrect, - timeMs: event.generationTimeMs, - cost: event.cost, - tokens: event.usage?.totalTokens, - }; - setState((prev) => ({ - ...prev, - guesses: { - ...prev.guesses, - [event.modelId]: { - guess: event.guess, - isCorrect, - timeMs: event.generationTimeMs, - }, - }, - })); - } else if (event.type === "error") { - console.error( - "[AI Duel] Guess error:", - event.modelId, - event.error, - ); - } else if (event.type === "complete") { - guessCost = event.totalCost || 0; - guessTokens = event.totalTokens || 0; - } - } catch (e) { - console.error("[AI Duel] Parse error:", e); - } - } - } - - // Phase 3: Scoring - setPhaseStatus("scoring"); - - // Find first correct guesser (by time) - const correctGuessers = Object.entries(roundGuesses) - .filter(([, g]) => g.isCorrect) - .sort(([, a], [, b]) => a.timeMs - b.timeMs); - - const winner = - correctGuessers.length > 0 ? correctGuessers[0][0] : null; - - // Update leaderboard - const finalLeaderboard = { ...newLeaderboard }; - correctGuessers.forEach(([modelId], index) => { - finalLeaderboard[modelId].correctGuesses += 1; - // First correct gets 3 points, others get 1 - finalLeaderboard[modelId].points += index === 0 ? 3 : 1; - }); - // Drawer gets 1 point if no one guessed correctly - if (!winner) { - finalLeaderboard[drawerId].points += 1; - } - - const roundResult: RoundResult = { - drawer: drawerId, - prompt, - svg: finalSvg, - guesses: Object.fromEntries( - Object.entries(roundGuesses).map(([k, v]) => [ - k, - { guess: v.guess, isCorrect: v.isCorrect }, - ]), - ), - winner, - }; - - // Auto-save each round to gallery (outside of state updater to avoid duplicate calls) - saveSession.mutate({ - mode: "ai_duel", - prompt, - totalCost: drawCost + guessCost, - totalTokens: drawTokens + guessTokens, - totalTimeMs: (drawTokens + guessTokens) * 10, // Approximate - drawings: [ - { - modelId: drawerId, - svg: finalSvg, - generationTimeMs: drawTokens * 10, // Approximate - cost: drawCost, - tokens: drawTokens, - isWinner: !winner, // Drawer wins if no one guessed correctly - chunks: drawChunks, - }, - ], - guesses: Object.entries(roundGuesses).map(([modelId, g]) => ({ - modelId, - guess: g.guess, - isCorrect: g.isCorrect, - generationTimeMs: g.timeMs, - cost: g.cost, - tokens: g.tokens, - })), - }); - - setState((prev) => { - const isFinished = roundNum >= prev.totalRounds; - if (isFinished && !isAuthenticated) { - setTimeout(() => setShowSignupPrompt(true), 1000); - } - return { - ...prev, - leaderboard: finalLeaderboard, - totalCost: prev.totalCost + guessCost, - totalTokens: prev.totalTokens + guessTokens, - roundHistory: [...prev.roundHistory, roundResult], - status: isFinished ? "finished" : "round-end", - }; - }); - - setPhaseStatus("idle"); - - // Auto-advance to next round (check current autoPlay state) - setState((prev) => { - if (roundNum < prev.totalRounds && prev.autoPlay) { - const delay = - prev.speed === "instant" - ? 500 - : prev.speed === "fast" - ? 1500 - : 3000; - autoPlayRef.current = setTimeout(() => { - const nextDrawerIndex = - (models.indexOf(drawerId) + 1) % models.length; - const nextDrawer = models[nextDrawerIndex]; - setState((s) => ({ - ...s, - currentRound: roundNum + 1, - status: "running", - })); - runRound(nextDrawer, models, finalLeaderboard, roundNum + 1); - }, delay); - } - return prev; - }); - } catch (error) { - console.error("Round error:", error); - setPhaseStatus("idle"); - setState((prev) => ({ ...prev, status: "paused" })); - } - }, - [svgToPng, saveSession, checkGuess, isAuthenticated], - ); - - const startDuel = useCallback(() => { - if (state.selectedModels.length < 3) return; - - // Initialize leaderboard - const initialLeaderboard: DuelState["leaderboard"] = {}; - state.selectedModels.forEach((id) => { - initialLeaderboard[id] = { draws: 0, correctGuesses: 0, points: 0 }; - }); - - setState((prev) => ({ - ...prev, - status: "running", - currentRound: 1, - leaderboard: initialLeaderboard, - roundHistory: [], - totalCost: 0, - totalTokens: 0, - autoPlay: true, - })); - - // Start first round - runRound( - state.selectedModels[0], - state.selectedModels, - initialLeaderboard, - 1, - ); - }, [state.selectedModels, runRound]); - - const continueToNextRound = useCallback(() => { - const nextDrawerIndex = - (state.selectedModels.indexOf(state.currentDrawer!) + 1) % - state.selectedModels.length; - const nextDrawer = state.selectedModels[nextDrawerIndex]; - setState((prev) => ({ - ...prev, - currentRound: prev.currentRound + 1, - status: "running", - })); - runRound( - nextDrawer, - state.selectedModels, - state.leaderboard, - state.currentRound + 1, - ); - }, [ - state.selectedModels, - state.currentDrawer, - state.leaderboard, - state.currentRound, - runRound, - ]); - - const sortedLeaderboard = useMemo(() => { - return Object.entries(state.leaderboard) - .map(([modelId, stats]) => ({ modelId, ...stats })) - .sort((a, b) => b.points - a.points); - }, [state.leaderboard]); - - return ( - -
-
- {/* Header */} -
- - - -
-

AI Duel

-

Watch AI compete

-
-
- - - - - {formatCost(state.totalCost)} - - - -

Total cost

-

- {state.totalTokens.toLocaleString()} tokens -

-
-
- - - {state.currentRound}/{state.totalRounds} - -
-
- - {/* Setup */} - {state.status === "setup" && ( -
-
- -

AI Duel Arena

-

- Select 3-6 AI models to compete. They'll take turns drawing - and guessing. -

-
- - - -
- {visionModels.map((model) => { - const isSelected = state.selectedModels.includes( - model.id, - ); - const isDisabled = - !isSelected && state.selectedModels.length >= 6; - return ( - - ); - })} -
-
-
- -
- - -
-
- )} - - {/* Running / Round End */} - {(state.status === "running" || - state.status === "round-end" || - state.status === "paused") && ( -
- {/* Round Info + Controls */} -
-
- - Round {state.currentRound}/{state.totalRounds} - - {phaseStatus !== "idle" && ( - - - {elapsedTime}s - - )} - -
-
- {phaseStatus === "drawing" && ( - - - {getModelById(state.currentDrawer!)?.name} is drawing... - - )} - {phaseStatus === "guessing" && ( - - - Models are guessing... - - )} - {phaseStatus === "scoring" && ( - - - Scoring... - - )} - {state.status === "round-end" && ( - Round Complete! - )} -
- {state.currentPrompt && ( - - “{state.currentPrompt}” - - )} -
- -
- {/* Drawing Area */} - -
- - Drawing - {state.currentDrawer && ( - - {getModelById(state.currentDrawer)?.name} - - )} -
-
- {state.currentSvg ? ( -
- ) : ( - - )} -
- - - {/* Guesses */} -
-
- - Guesses - - {Object.keys(state.guesses).length}/ - {state.selectedModels.length - 1} - -
- {state.selectedModels - .filter((id) => id !== state.currentDrawer) - .map((modelId) => { - const model = getModelById(modelId); - const guess = state.guesses[modelId]; - if (!model) return null; - - return ( - - -
-
- {model.name} -
- {!guess && phaseStatus === "guessing" && ( - - - thinking... - - )} - {guess?.isCorrect && ( - - - Correct! - - )} - {guess && - !guess.isCorrect && - state.status === "round-end" && ( - - - Wrong - - )} - {guess?.timeMs && ( - - {(guess.timeMs / 1000).toFixed(1)}s - - )} -
- {/* Guess text - always show prominently */} -
- {guess ? ( - - “{guess.guess}” - - ) : ( - - Waiting for guess... - - )} -
- - - ); - })} -
-
- - {/* Leaderboard */} - - -
- - Leaderboard -
-
- {sortedLeaderboard.map((entry, index) => { - const model = getModelById(entry.modelId); - if (!model) return null; - return ( -
0 && - "bg-yellow-500/10 border-yellow-500/50", - )} - > -
- - #{index + 1} - -
- - {model.name} - -
-
- {entry.points} -
-
- {entry.correctGuesses} correct -
-
- ); - })} -
- - - - {/* Controls */} - {state.status === "round-end" && - state.currentRound < state.totalRounds && - !state.autoPlay && ( -
- - -
- )} - {state.status === "round-end" && - state.currentRound < state.totalRounds && - state.autoPlay && ( -
- - - Next round starting... - -
- )} -
- )} - - {/* Finished */} - {state.status === "finished" && ( -
-
- -

Duel Complete!

-

- {state.totalRounds} rounds played -

-
- - {/* Winner */} - {sortedLeaderboard[0] && ( - - -
-

- {getModelById(sortedLeaderboard[0].modelId)?.name} -

-

- Champion with {sortedLeaderboard[0].points} points -

- - - )} - - {/* Final Leaderboard */} - - -

Final Standings

-
- {sortedLeaderboard.map((entry, index) => { - const model = getModelById(entry.modelId); - if (!model) return null; - const percentage = - state.totalRounds > 0 - ? (entry.correctGuesses / - (state.totalRounds - entry.draws)) * - 100 - : 0; - - return ( -
-
-
- - {index + 1} - -
- {model.name} -
-
- - {entry.points} pts - - - ({entry.correctGuesses} correct, {entry.draws}{" "} - draws) - -
-
- -
- ); - })} -
- - - - {/* Stats */} -
-
- - Total: {formatCost(state.totalCost)} -
-
- - {state.totalTokens.toLocaleString()} tokens -
-
- - {/* Actions */} -
- - -
-
- )} -
-
- -
- ); -} diff --git a/app/play/human-judge/page.tsx b/app/play/human-judge/page.tsx deleted file mode 100644 index 823604d..0000000 --- a/app/play/human-judge/page.tsx +++ /dev/null @@ -1,1216 +0,0 @@ -"use client"; - -import { - ArrowLeft, - Check, - Clock, - Coins, - DollarSign, - Play, - RotateCcw, - Settings2, - Shuffle, - Sparkles, - Trophy, - X, -} from "lucide-react"; -import Link from "next/link"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { SignupPrompt } from "@/components/signup-prompt"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Progress } from "@/components/ui/progress"; -import { Spinner } from "@/components/ui/spinner"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useSaveSession } from "@/lib/hooks/use-gallery"; -import { useUserIdentity } from "@/lib/hooks/use-user-identity"; -import { - AVAILABLE_MODELS, - DEFAULT_MODELS, - formatCost, - getModelById, - getUniqueProviders, - shuffleModels, -} from "@/lib/models"; -import { getRandomPrompt } from "@/lib/prompts"; -import type { Drawing } from "@/lib/types"; -import { cn } from "@/lib/utils"; - -interface GameState { - status: "setup" | "idle" | "generating" | "voting" | "results"; - prompt: string; - drawings: Drawing[]; - selectedDrawing: string | null; - leaderboard: Record; - roundsPlayed: number; - totalTimeMs: number; - roundCost: number; - totalCost: number; - totalTokens: number; - selectedModels: string[]; -} - -interface GeneratingModel { - id: string; - name: string; - color: string; - status: "pending" | "streaming" | "done" | "error"; - svg?: string; - timeMs?: number; -} - -type FilterType = "all" | "budget" | "mid" | "premium" | "flagship"; - -export default function HumanJudgePage() { - const saveSession = useSaveSession(); - const { isAuthenticated } = useUserIdentity(); - const [showSignupPrompt, setShowSignupPrompt] = useState(false); - const [hasShownPrompt, setHasShownPrompt] = useState(false); - const [gameState, setGameState] = useState({ - status: "setup", - prompt: "", - drawings: [], - selectedDrawing: null, - leaderboard: {}, - roundsPlayed: 0, - totalTimeMs: 0, - roundCost: 0, - totalCost: 0, - totalTokens: 0, - selectedModels: DEFAULT_MODELS, - }); - - const [generatingModels, setGeneratingModels] = useState( - [], - ); - const [providerFilter, setProviderFilter] = useState("all"); - const [tierFilter, setTierFilter] = useState("all"); - const [elapsedTime, setElapsedTime] = useState(0); - const startTimeRef = useRef(null); - - // Timer for elapsed time during generation - useEffect(() => { - if (gameState.status === "generating") { - startTimeRef.current = Date.now(); - const interval = setInterval(() => { - if (startTimeRef.current) { - setElapsedTime( - Math.floor((Date.now() - startTimeRef.current) / 1000), - ); - } - }, 1000); - return () => clearInterval(interval); - } else { - setElapsedTime(0); - startTimeRef.current = null; - } - }, [gameState.status]); - - const providers = useMemo(() => getUniqueProviders(), []); - - const filteredModels = useMemo(() => { - return AVAILABLE_MODELS.filter((m) => { - const matchesProvider = - providerFilter === "all" || m.provider === providerFilter; - const matchesTier = tierFilter === "all" || m.tier === tierFilter; - return matchesProvider && matchesTier; - }); - }, [providerFilter, tierFilter]); - - const toggleModel = useCallback((modelId: string) => { - setGameState((prev) => ({ - ...prev, - selectedModels: prev.selectedModels.includes(modelId) - ? prev.selectedModels.filter((id) => id !== modelId) - : prev.selectedModels.length < 8 - ? [...prev.selectedModels, modelId] - : prev.selectedModels, // Max 8 models - })); - }, []); - - const removeModel = useCallback((modelId: string) => { - setGameState((prev) => ({ - ...prev, - selectedModels: prev.selectedModels.filter((id) => id !== modelId), - })); - }, []); - - const shuffleSelection = useCallback((count: number) => { - const shuffled = shuffleModels(AVAILABLE_MODELS, count); - setGameState((prev) => ({ - ...prev, - selectedModels: shuffled.map((m) => m.id), - })); - }, []); - - const startRound = useCallback(async () => { - const prompt = getRandomPrompt(); - - setGameState((prev) => ({ - ...prev, - status: "generating", - prompt, - drawings: [], - selectedDrawing: null, - roundCost: 0, - })); - - // Initialize generating models state - setGeneratingModels( - gameState.selectedModels.map((modelId) => { - const model = getModelById(modelId); - return { - id: modelId, - name: model?.name || modelId, - color: model?.color || "#888", - status: "pending", - }; - }), - ); - - try { - const response = await fetch("/api/generate-drawings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ prompt, models: gameState.selectedModels }), - }); - - if (!response.ok) { - throw new Error("Failed to generate drawings"); - } - - const reader = response.body?.getReader(); - if (!reader) throw new Error("No response body"); - - const decoder = new TextDecoder(); - let buffer = ""; - const completedDrawings: Drawing[] = []; - const chunksMap = new Map(); // Track chunks per model for replay - let totalCost = 0; - let totalTokens = 0; - let totalTimeMs = 0; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const jsonStr = line.slice(6); - - try { - const event = JSON.parse(jsonStr); - - if (event.type === "start") { - // Mark all models as pending (waiting to stream) - setGeneratingModels((prev) => - prev.map((m) => ({ ...m, status: "pending" })), - ); - } else if (event.type === "partial") { - // Collect chunks for replay - if (!chunksMap.has(event.modelId)) { - chunksMap.set(event.modelId, []); - } - chunksMap.get(event.modelId)?.push(event.svg); - - // Update partial SVG as it streams in - setGeneratingModels((prev) => - prev.map((m) => - m.id === event.modelId - ? { ...m, status: "streaming", svg: event.svg } - : m, - ), - ); - } else if (event.type === "drawing") { - // Update the specific model as done with its drawing - setGeneratingModels((prev) => - prev.map((m) => - m.id === event.modelId - ? { - ...m, - status: "done", - svg: event.svg, - timeMs: event.generationTimeMs, - } - : m, - ), - ); - completedDrawings.push({ - modelId: event.modelId, - svg: event.svg, - generationTimeMs: event.generationTimeMs, - usage: event.usage, - cost: event.cost, - chunks: chunksMap.get(event.modelId) || [], - }); - } else if (event.type === "error") { - // Mark as error - setGeneratingModels((prev) => - prev.map((m) => - m.id === event.modelId - ? { ...m, status: "error", timeMs: event.generationTimeMs } - : m, - ), - ); - } else if (event.type === "complete") { - totalCost = event.totalCost; - totalTokens = event.totalTokens; - totalTimeMs = event.totalTimeMs; - } - } catch (e) { - console.error("Failed to parse SSE event:", e); - } - } - } - - // Shuffle drawings to anonymize them - const shuffledDrawings = [...completedDrawings].sort( - () => Math.random() - 0.5, - ); - - setGameState((prev) => ({ - ...prev, - status: "voting", - drawings: shuffledDrawings, - totalTimeMs, - roundCost: totalCost, - totalCost: prev.totalCost + totalCost, - totalTokens: prev.totalTokens + totalTokens, - })); - } catch (error) { - console.error("Error:", error); - setGameState((prev) => ({ - ...prev, - status: "idle", - })); - } - }, [gameState.selectedModels]); - - const startGame = useCallback(async () => { - if (gameState.selectedModels.length < 2) return; - setGameState((prev) => ({ - ...prev, - leaderboard: {}, - roundsPlayed: 0, - totalCost: 0, - totalTokens: 0, - })); - // Go directly to generating - await startRound(); - }, [gameState.selectedModels.length, startRound]); - - const selectDrawing = useCallback((modelId: string) => { - setGameState((prev) => ({ - ...prev, - selectedDrawing: modelId, - })); - }, []); - - const confirmVote = useCallback(() => { - if (!gameState.selectedDrawing) return; - - // Save to gallery (outside of state updater to avoid duplicate calls) - saveSession.mutate({ - mode: "human_judge", - prompt: gameState.prompt, - totalCost: gameState.roundCost, - totalTokens: gameState.totalTokens, - totalTimeMs: gameState.totalTimeMs, - drawings: gameState.drawings.map((d) => ({ - modelId: d.modelId, - svg: d.svg, - generationTimeMs: d.generationTimeMs, - cost: d.cost, - tokens: d.usage?.totalTokens, - isWinner: d.modelId === gameState.selectedDrawing, - chunks: d.chunks, - })), - }); - - setGameState((prev) => { - const newState = { - ...prev, - status: "results" as const, - leaderboard: { - ...prev.leaderboard, - [prev.selectedDrawing!]: - (prev.leaderboard[prev.selectedDrawing!] || 0) + 1, - }, - roundsPlayed: prev.roundsPlayed + 1, - }; - - if (!isAuthenticated && !hasShownPrompt) { - setTimeout(() => { - setShowSignupPrompt(true); - setHasShownPrompt(true); - }, 1000); - } - - return newState; - }); - }, [ - gameState.selectedDrawing, - gameState.prompt, - gameState.drawings, - gameState.roundCost, - gameState.totalTokens, - gameState.totalTimeMs, - saveSession, - isAuthenticated, - hasShownPrompt, - ]); - - const playAgain = useCallback(() => { - startRound(); - }, [startRound]); - - return ( - -
-
- {/* Header */} -
- - - -
-

Human Judge

-

- Pick the best drawing -

-
-
- - - - - {formatCost(gameState.totalCost)} - - - -

Total session cost

-

- {gameState.totalTokens.toLocaleString()} tokens used -

-
-
- - - Round {gameState.roundsPlayed + 1} - -
-
- - {/* Setup - Model Selection */} - {gameState.status === "setup" && ( -
- {/* Left: Model Grid */} - -
- v && setProviderFilter(v)} - size="sm" - > - All - {providers.slice(0, 6).map((provider) => ( - - {provider} - - ))} - -
- v && setTierFilter(v as FilterType)} - size="sm" - > - All - $ - $$ - $$$ - $$$$ - -
- -
- {filteredModels.map((model) => { - const isSelected = gameState.selectedModels.includes( - model.id, - ); - const isDisabled = - !isSelected && gameState.selectedModels.length >= 8; - return ( - - ); - })} -
-
- - - {/* Right: Your Squad */} -
- - -
-

Your Squad

- - {gameState.selectedModels.length}/8 - -
- -
- {gameState.selectedModels.length === 0 ? ( -

- Select models from the grid -

- ) : ( - gameState.selectedModels.map((modelId, index) => { - const model = getModelById(modelId); - if (!model) return null; - return ( -
- - {index + 1} - - - - {model.name} - - -
- ); - }) - )} -
- -
- - -
- - -
-
-
-
- )} - - {/* Game Area */} - {gameState.status === "idle" && ( -
-
- -

Ready to play?

-

- {gameState.selectedModels.length} AI models will draw a - concept. Your job is to pick the one that best captures the - idea. -

-
-
- - -
-
- )} - - {gameState.status === "generating" && ( -
-
- - “{gameState.prompt}” - -
-
- - - {elapsedTime}s - -
-
-
-
- {generatingModels.map((m) => ( -
- ))} -
- - {generatingModels.filter((m) => m.status === "streaming") - .length > 0 && ( - - { - generatingModels.filter( - (m) => m.status === "streaming", - ).length - }{" "} - drawing - - )} - {generatingModels.filter((m) => m.status === "streaming") - .length > 0 && - generatingModels.filter((m) => m.status === "done") - .length > 0 && - " · "} - {generatingModels.filter((m) => m.status === "done") - .length > 0 && ( - - { - generatingModels.filter((m) => m.status === "done") - .length - }{" "} - done - - )} - {generatingModels.filter((m) => m.status === "streaming") - .length === 0 && - generatingModels.filter((m) => m.status === "done") - .length === 0 && Connecting...} - -
-
-
- -
- {generatingModels.map((model) => ( - - -
- {model.status === "pending" && ( -
- - Connecting... -
- )} - {(model.status === "streaming" || - model.status === "done") && - model.svg && ( -
- )} - {model.status === "error" && ( -
- - Failed -
- )} - {(model.status === "streaming" || - model.status === "done") && ( -
- - {model.status === "streaming" && ( - - )} - {model.svg - ? `${(model.svg.length / 1000).toFixed(1)}kb` - : "0kb"} - - - {model.status === "done" && model.timeMs - ? `${(model.timeMs / 1000).toFixed(1)}s` - : `${elapsedTime}s`} - -
- )} -
-
-
-
- - ??? - -
-
- - - ))} -
-
- )} - - {gameState.status === "voting" && ( -
-
- - “{gameState.prompt}” - -

- Pick your favorite -

-
-
- - - {(gameState.totalTimeMs / 1000).toFixed(1)}s - -
-
-
- - - {formatCost(gameState.roundCost)} - -
-
-
- -
- {generatingModels.map((model, index) => { - const drawing = gameState.drawings.find( - (d) => d.modelId === model.id, - ); - const isError = model.status === "error"; - const isSelected = gameState.selectedDrawing === model.id; - - if (isError) { - return ( - - -
-
- - Failed -
-
-
-
- - {String.fromCharCode(65 + index)} - - - Error - -
-
-
-
- ); - } - - if (!drawing) return null; - - return ( - selectDrawing(model.id)} - > - -
-
-
- {isSelected && ( -
- -
- )} -
-
-
-
- - {String.fromCharCode(65 + index)} - - - {(drawing.generationTimeMs / 1000).toFixed(1)}s - -
-
- - - ); - })} -
- -
- -
-
- )} - - {gameState.status === "results" && ( -
-
- - “{gameState.prompt}” - -

And the winner is...

-
-
- - - Round:{" "} - - {formatCost(gameState.roundCost)} - - -
-
-
- - - Session:{" "} - - {formatCost(gameState.totalCost)} - - -
-
-
- -
- {generatingModels.map((genModel) => { - const drawing = gameState.drawings.find( - (d) => d.modelId === genModel.id, - ); - const model = getModelById(genModel.id); - const isError = genModel.status === "error"; - const isWinner = gameState.selectedDrawing === genModel.id; - - if (isError) { - return ( - - -
-
- - Failed -
-
-
-
-
- - {model?.name} - -
-
- Generation failed -
-
- - - ); - } - - if (!drawing) return null; - - return ( - - -
-
- {isWinner && ( -
- - - Winner - -
- )} -
-
-
-
- - {model?.name} - -
-
- - {(drawing.generationTimeMs / 1000).toFixed(1)}s - - - - {formatCost(drawing.cost || 0)} - - -

- {drawing.usage?.totalTokens?.toLocaleString() || - 0}{" "} - tokens -

-

- In:{" "} - {drawing.usage?.promptTokens?.toLocaleString() || - 0}{" "} - / Out:{" "} - {drawing.usage?.completionTokens?.toLocaleString() || - 0} -

-
-
-
-
- - - ); - })} -
- - {/* Leaderboard */} - -
-
-
- -

Leaderboard

-
-
- - {gameState.roundsPlayed} rounds - -
- - {gameState.totalTokens.toLocaleString()} tokens - -
-
-
- -
- {[...gameState.selectedModels] - .sort( - (a, b) => - (gameState.leaderboard[b] || 0) - - (gameState.leaderboard[a] || 0), - ) - .map((modelId, index) => { - const model = getModelById(modelId); - if (!model) return null; - const wins = gameState.leaderboard[model.id] || 0; - const percentage = - gameState.roundsPlayed > 0 - ? (wins / gameState.roundsPlayed) * 100 - : 0; - const isLeader = index === 0 && wins > 0; - - return ( -
-
-
- - {index + 1} - -
- - {model.name} - - {isLeader && ( - - )} -
- - {wins} win{wins !== 1 ? "s" : ""} ( - {percentage.toFixed(0)}%) - -
- div]:bg-yellow-500", - )} - /> -
- ); - })} -
- - - -
- - - -
-
- )} -
-
- -
- ); -} diff --git a/app/play/model-guess/page.tsx b/app/play/model-guess/page.tsx deleted file mode 100644 index 52b5e7d..0000000 --- a/app/play/model-guess/page.tsx +++ /dev/null @@ -1,828 +0,0 @@ -"use client"; - -import { - ArrowLeft, - Check, - Clock, - DollarSign, - Eye, - Pencil, - Play, - RotateCcw, - Send, - Shuffle, - Trophy, - X, -} from "lucide-react"; -import Link from "next/link"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { DrawingCanvas } from "@/components/drawing-canvas"; -import { SignupPrompt } from "@/components/signup-prompt"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Spinner } from "@/components/ui/spinner"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useSaveSession } from "@/lib/hooks/use-gallery"; -import { useUserIdentity } from "@/lib/hooks/use-user-identity"; -import { - DEFAULT_VISION_MODELS, - formatCost, - getModelById, - getVisionModels, - shuffleModels, -} from "@/lib/models"; -import { getRandomPrompt } from "@/lib/prompts"; -import { cn } from "@/lib/utils"; - -interface GameState { - status: "setup" | "drawing" | "guessing" | "results"; - prompt: string; - selectedModels: string[]; - guesses: Guess[]; - leaderboard: Record; - roundsPlayed: number; - totalCost: number; - totalTokens: number; -} - -interface Guess { - modelId: string; - guess: string; - isCorrect: boolean; - generationTimeMs: number; - cost?: number; - tokens?: number; -} - -interface GuessingModel { - id: string; - name: string; - color: string; - status: "pending" | "guessing" | "done" | "error"; - guess?: string; - timeMs?: number; - isCorrect?: boolean; -} - -export default function ModelGuessPage() { - const saveSession = useSaveSession(); - const { isAuthenticated } = useUserIdentity(); - const [showSignupPrompt, setShowSignupPrompt] = useState(false); - const [hasShownPrompt, setHasShownPrompt] = useState(false); - const [gameState, setGameState] = useState({ - status: "setup", - prompt: "", - selectedModels: DEFAULT_VISION_MODELS, - guesses: [], - leaderboard: {}, - roundsPlayed: 0, - totalCost: 0, - totalTokens: 0, - }); - - const [guessingModels, setGuessingModels] = useState([]); - const [drawingDataUrl, setDrawingDataUrl] = useState(""); - const [elapsedTime, setElapsedTime] = useState(0); - const startTimeRef = useRef(null); - - const visionModels = useMemo(() => getVisionModels(), []); - - // Timer for elapsed time during guessing - useEffect(() => { - if (gameState.status === "guessing") { - startTimeRef.current = Date.now(); - const interval = setInterval(() => { - if (startTimeRef.current) { - setElapsedTime( - Math.floor((Date.now() - startTimeRef.current) / 1000), - ); - } - }, 1000); - return () => clearInterval(interval); - } else { - setElapsedTime(0); - startTimeRef.current = null; - } - }, [gameState.status]); - - const toggleModel = useCallback((modelId: string) => { - setGameState((prev) => ({ - ...prev, - selectedModels: prev.selectedModels.includes(modelId) - ? prev.selectedModels.filter((id) => id !== modelId) - : prev.selectedModels.length < 6 - ? [...prev.selectedModels, modelId] - : prev.selectedModels, - })); - }, []); - - const shuffleSelection = useCallback(() => { - const shuffled = shuffleModels(visionModels, 4); - setGameState((prev) => ({ - ...prev, - selectedModels: shuffled.map((m) => m.id), - })); - }, [visionModels]); - - const startGame = useCallback(() => { - if (gameState.selectedModels.length < 2) return; - const prompt = getRandomPrompt(); - setGameState((prev) => ({ - ...prev, - status: "drawing", - prompt, - guesses: [], - leaderboard: prev.roundsPlayed === 0 ? {} : prev.leaderboard, - })); - }, [gameState.selectedModels.length]); - - // Check if guess matches prompt (simple fuzzy matching) - const checkGuess = useCallback((guess: string, prompt: string): boolean => { - const normalizedGuess = guess.toLowerCase().trim(); - const normalizedPrompt = prompt.toLowerCase().trim(); - - // Direct match - if ( - normalizedGuess.includes(normalizedPrompt) || - normalizedPrompt.includes(normalizedGuess) - ) { - return true; - } - - // Check individual words - const promptWords = normalizedPrompt.split(/\s+/); - const guessWords = normalizedGuess.split(/\s+/); - - // If any significant word matches - const significantWords = promptWords.filter((w) => w.length > 2); - return significantWords.some((word) => - guessWords.some((gw) => gw.includes(word) || word.includes(gw)), - ); - }, []); - - const submitDrawing = useCallback(async () => { - if (!drawingDataUrl) return; - - setGameState((prev) => ({ - ...prev, - status: "guessing", - })); - - // Initialize guessing models state - setGuessingModels( - gameState.selectedModels.map((modelId) => { - const model = getModelById(modelId); - return { - id: modelId, - name: model?.name || modelId, - color: model?.color || "#888", - status: "pending", - }; - }), - ); - - try { - const response = await fetch("/api/guess-drawing", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - imageDataUrl: drawingDataUrl, - models: gameState.selectedModels, - }), - }); - - if (!response.ok) { - throw new Error("Failed to get guesses"); - } - - const reader = response.body?.getReader(); - if (!reader) throw new Error("No response body"); - - const decoder = new TextDecoder(); - let buffer = ""; - const completedGuesses: Guess[] = []; - let totalCost = 0; - let totalTokens = 0; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const jsonStr = line.slice(6); - - try { - const event = JSON.parse(jsonStr); - - if (event.type === "start") { - setGuessingModels((prev) => - prev.map((m) => ({ ...m, status: "pending" })), - ); - } else if (event.type === "partial") { - setGuessingModels((prev) => - prev.map((m) => - m.id === event.modelId - ? { ...m, status: "guessing", guess: event.guess } - : m, - ), - ); - } else if (event.type === "guess") { - const isCorrect = checkGuess(event.guess, gameState.prompt); - setGuessingModels((prev) => - prev.map((m) => - m.id === event.modelId - ? { - ...m, - status: "done", - guess: event.guess, - timeMs: event.generationTimeMs, - isCorrect, - } - : m, - ), - ); - completedGuesses.push({ - modelId: event.modelId, - guess: event.guess, - isCorrect, - generationTimeMs: event.generationTimeMs, - cost: event.cost, - tokens: event.usage?.totalTokens, - }); - } else if (event.type === "error") { - setGuessingModels((prev) => - prev.map((m) => - m.id === event.modelId - ? { ...m, status: "error", timeMs: event.generationTimeMs } - : m, - ), - ); - } else if (event.type === "complete") { - totalCost = event.totalCost; - totalTokens = event.totalTokens; - } - } catch (e) { - console.error("Failed to parse SSE event:", e); - } - } - } - - // Calculate scores - first correct guess gets a point - const firstCorrect = completedGuesses.find((g) => g.isCorrect); - const newLeaderboard = { ...gameState.leaderboard }; - if (firstCorrect) { - newLeaderboard[firstCorrect.modelId] = - (newLeaderboard[firstCorrect.modelId] || 0) + 1; - } - - // Auto-save to gallery (outside of state updater to avoid duplicate calls) - saveSession.mutate({ - mode: "model_guess", - prompt: gameState.prompt, - totalCost: totalCost, - totalTokens: totalTokens, - drawings: [], - guesses: completedGuesses.map((g) => ({ - modelId: g.modelId, - guess: g.guess, - isCorrect: g.isCorrect, - generationTimeMs: g.generationTimeMs, - cost: g.cost, - tokens: g.tokens, - })), - }); - - setGameState((prev) => { - const newState = { - ...prev, - status: "results" as const, - guesses: completedGuesses, - leaderboard: newLeaderboard, - roundsPlayed: prev.roundsPlayed + 1, - totalCost: prev.totalCost + totalCost, - totalTokens: prev.totalTokens + totalTokens, - }; - - if (!isAuthenticated && !hasShownPrompt) { - setTimeout(() => { - setShowSignupPrompt(true); - setHasShownPrompt(true); - }, 1000); - } - - return newState; - }); - } catch (error) { - console.error("Error:", error); - setGameState((prev) => ({ - ...prev, - status: "drawing", - })); - } - }, [ - drawingDataUrl, - gameState.selectedModels, - gameState.prompt, - gameState.leaderboard, - checkGuess, - hasShownPrompt, - isAuthenticated, // Auto-save to gallery (outside of state updater to avoid duplicate calls) - saveSession.mutate, - ]); - - const playAgain = useCallback(() => { - const prompt = getRandomPrompt(); - setDrawingDataUrl(""); - setGameState((prev) => ({ - ...prev, - status: "drawing", - prompt, - guesses: [], - })); - }, []); - - return ( - -
-
- {/* Header */} -
- - - -
-

Model Guess

-

- You draw, AI guesses -

-
-
- - - - - {formatCost(gameState.totalCost)} - - - -

Total session cost

-

- {gameState.totalTokens.toLocaleString()} tokens used -

-
-
- - - Round {gameState.roundsPlayed + 1} - -
-
- - {/* Setup - Model Selection */} - {gameState.status === "setup" && ( -
-
- -

Select Vision Models

-

- Choose AI models that will try to guess your drawings -

-
- - - -
- {visionModels.map((model) => { - const isSelected = gameState.selectedModels.includes( - model.id, - ); - const isDisabled = - !isSelected && gameState.selectedModels.length >= 6; - return ( - - ); - })} -
-
-
- -
- - -
-
- )} - - {/* Drawing Phase */} - {gameState.status === "drawing" && ( -
-
- - Draw: “{gameState.prompt}” - -

- Draw the concept below, then submit for AI to guess -

-
- -
- - -
- - -
-
- - {/* Selected Models */} -
- {gameState.selectedModels.map((modelId) => { - const model = getModelById(modelId); - if (!model) return null; - return ( - - - {model.name} - - ); - })} -
-
- )} - - {/* Guessing Phase */} - {gameState.status === "guessing" && ( -
-
- - “{gameState.prompt}” - -
-
- - - {elapsedTime}s - -
-
- - {guessingModels.filter((m) => m.status === "done").length}/ - {guessingModels.length} guessed - -
-
- -
- {/* Drawing Preview */} - -
- {drawingDataUrl && ( - Your drawing - )} -
-
- - {/* Guesses */} -
- {guessingModels.map((model) => ( - - -
-
- - {model.name} - - {model.status === "pending" && ( - - )} - {model.status === "guessing" && ( - - )} - {model.status === "done" && model.timeMs && ( - - {(model.timeMs / 1000).toFixed(1)}s - - )} - {model.status === "done" && model.isCorrect && ( - - )} -
- {(model.status === "guessing" || - model.status === "done") && - model.guess && ( -
- “{model.guess}” -
- )} - {model.status === "error" && ( -
- Failed to guess -
- )} - - - ))} -
-
-
- )} - - {/* Results Phase */} - {gameState.status === "results" && ( -
-
- - Answer: “{gameState.prompt}” - -

- {gameState.guesses.some((g) => g.isCorrect) - ? "Someone got it!" - : "No one guessed it!"} -

-
- -
- {/* Drawing */} - -
- {drawingDataUrl && ( - Your drawing - )} -
-
- - {/* Results */} -
- {guessingModels.map((model) => { - const guess = gameState.guesses.find( - (g) => g.modelId === model.id, - ); - const isWinner = - guess?.isCorrect && - gameState.guesses.find((g) => g.isCorrect)?.modelId === - model.id; - - return ( - - -
-
- - {model.name} - - {isWinner && ( - - - Winner - - )} - {guess?.isCorrect && !isWinner && ( - - Correct - - )} - {guess && !guess.isCorrect && ( - - )} -
- {guess && ( -
- “{guess.guess}” -
- )} - {model.status === "error" && ( -
- Failed to guess -
- )} - - - ); - })} -
-
- - {/* Leaderboard */} - {Object.keys(gameState.leaderboard).length > 0 && ( - - -
- -

Leaderboard

- - {gameState.roundsPlayed} rounds - -
-
- {Object.entries(gameState.leaderboard) - .sort(([, a], [, b]) => b - a) - .map(([modelId, wins], index) => { - const model = getModelById(modelId); - if (!model) return null; - return ( -
- - {index + 1} - -
- {model.name} - - {wins} win{wins !== 1 ? "s" : ""} - -
- ); - })} -
- - - )} - -
- - - -
-
- )} -
-
- -
- ); -} diff --git a/app/play/page.tsx b/app/play/page.tsx new file mode 100644 index 0000000..89efbdc --- /dev/null +++ b/app/play/page.tsx @@ -0,0 +1,1387 @@ +"use client"; + +import { + ArrowLeft, + Bot, + Check, + Clock, + DollarSign, + Play, + RotateCcw, + Send, + Shuffle, + Trophy, + User, + Zap, +} from "lucide-react"; +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { DrawingCanvas } from "@/components/drawing-canvas"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + useCreateSession, + useSaveRound, + useSaveSession, +} from "@/lib/hooks/use-gallery"; +import { + DEFAULT_VISION_MODELS, + formatCost, + getModelById, + getVisionModels, + shuffleModels, +} from "@/lib/models"; +import { getRandomPrompt } from "@/lib/prompts"; +import { cn } from "@/lib/utils"; + +type Participant = { + id: string; + name: string; + type: "human" | "llm"; + color: string; + score: number; + drawingScore: number; + guessingScore: number; +}; + +type GuessResult = { + participantId: string; + guess: string; + semanticScore: number; + timeBonus: number; + finalScore: number; + timeMs: number; +}; + +type RoundResult = { + drawerId: string; + drawerType: "human" | "llm"; + prompt: string; + svg?: string; + imageDataUrl?: string; + guesses: GuessResult[]; + maxPossibleScore?: number; + topScore?: number; +}; + +type GameState = { + status: "setup" | "drawing" | "guessing" | "round-results" | "game-over"; + sessionId: string | null; + participants: Participant[]; + currentRound: number; + totalRounds: number; + currentDrawerIndex: number; + currentPrompt: string; + currentDrawing: string | null; + roundHistory: RoundResult[]; + totalCost: number; + totalTokens: number; + drawingTimeLimit: number; +}; + +const HUMAN_PARTICIPANT: Participant = { + id: "human", + name: "You", + type: "human", + color: "#10b981", + score: 0, + drawingScore: 0, + guessingScore: 0, +}; + +export default function PictionaryPage() { + const [gameState, setGameState] = useState({ + status: "setup", + sessionId: null, + participants: [HUMAN_PARTICIPANT], + currentRound: 0, + totalRounds: 4, + currentDrawerIndex: 0, + currentPrompt: "", + currentDrawing: null, + roundHistory: [], + totalCost: 0, + totalTokens: 0, + drawingTimeLimit: 60, + }); + + const [selectedModels, setSelectedModels] = useState( + DEFAULT_VISION_MODELS.slice(0, 3), + ); + const [drawingDataUrl, setDrawingDataUrl] = useState(""); + const [humanGuess, setHumanGuess] = useState(""); + const [timeRemaining, setTimeRemaining] = useState(60); + const [llmGuesses, setLlmGuesses] = useState>({}); + const [isSubmittingGuess, setIsSubmittingGuess] = useState(false); + const [humanGuessSubmitted, setHumanGuessSubmitted] = useState(false); + const [llmDrawingSvg, setLlmDrawingSvg] = useState(null); + const [isGeneratingDrawing, setIsGeneratingDrawing] = useState(false); + + const saveSession = useSaveSession(); + const saveRound = useSaveRound(); + const createSession = useCreateSession(); + const timerRef = useRef(null); + const guessStartTimeRef = useRef(0); + const autoTransitionTimerRef = useRef(null); + const [autoTransitionCountdown, setAutoTransitionCountdown] = useState< + number | null + >(null); + const finishGuessingCalledRef = useRef(false); + const finishGuessingRef = useRef<() => void>(() => {}); + const handleDrawingCompleteRef = useRef<() => void>(() => {}); + const nextRoundRef = useRef<() => void>(() => {}); + + const visionModels = useMemo(() => getVisionModels(), []); + + const currentDrawer = useMemo(() => { + return gameState.participants[gameState.currentDrawerIndex]; + }, [gameState.participants, gameState.currentDrawerIndex]); + + const isHumanDrawing = currentDrawer?.type === "human"; + + useEffect(() => { + if (gameState.status === "drawing" || gameState.status === "guessing") { + setTimeRemaining(gameState.drawingTimeLimit); + timerRef.current = setInterval(() => { + setTimeRemaining((prev) => { + if (prev <= 1) { + if (timerRef.current) clearInterval(timerRef.current); + if (gameState.status === "drawing") { + handleDrawingComplete(); + } else if (gameState.status === "guessing") { + finishGuessing(); + } + return 0; + } + return prev - 1; + }); + }, 1000); + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + } + }, [gameState.status, gameState.currentRound]); + + const toggleModel = useCallback((modelId: string) => { + setSelectedModels((prev) => + prev.includes(modelId) + ? prev.filter((id) => id !== modelId) + : prev.length < 5 + ? [...prev, modelId] + : prev, + ); + }, []); + + const shuffleSelection = useCallback(() => { + const shuffled = shuffleModels(visionModels, 3); + setSelectedModels(shuffled.map((m) => m.id)); + }, [visionModels]); + + const startGame = useCallback(async () => { + if (selectedModels.length < 1) return; + + const llmParticipants: Participant[] = selectedModels.map((modelId) => { + const model = getModelById(modelId); + return { + id: modelId, + name: model?.name || modelId, + type: "llm", + color: model?.color || "#888", + score: 0, + drawingScore: 0, + guessingScore: 0, + }; + }); + + const allParticipants = [HUMAN_PARTICIPANT, ...llmParticipants]; + const prompt = getRandomPrompt(); + const totalRounds = allParticipants.length * 2; + + let sessionId: string | null = null; + try { + const response = await createSession.mutateAsync({ + mode: "pictionary", + prompt, + totalRounds, + participants: allParticipants.map((p) => p.id), + }); + sessionId = response.sessionId; + } catch (error) { + console.error("Failed to create session:", error); + } + + setGameState({ + status: "drawing", + sessionId, + participants: allParticipants, + currentRound: 1, + totalRounds, + currentDrawerIndex: 0, + currentPrompt: prompt, + currentDrawing: null, + roundHistory: [], + totalCost: 0, + totalTokens: 0, + drawingTimeLimit: 60, + }); + setDrawingDataUrl(""); + setLlmDrawingSvg(null); + setHumanGuess(""); + setLlmGuesses({}); + setHumanGuessSubmitted(false); + }, [selectedModels, createSession]); + + const handleDrawingComplete = useCallback(async () => { + if (timerRef.current) clearInterval(timerRef.current); + if (autoTransitionTimerRef.current) + clearInterval(autoTransitionTimerRef.current); + + if (isHumanDrawing && !drawingDataUrl) { + return; + } + + finishGuessingCalledRef.current = false; + setAutoTransitionCountdown(null); + + setGameState((prev) => ({ + ...prev, + status: "guessing", + currentDrawing: isHumanDrawing ? drawingDataUrl : llmDrawingSvg, + })); + + guessStartTimeRef.current = Date.now(); + setTimeRemaining(gameState.drawingTimeLimit); + + if (!isHumanDrawing) { + triggerLlmGuesses(llmDrawingSvg!); + } else { + triggerLlmGuesses(drawingDataUrl); + } + }, [ + isHumanDrawing, + drawingDataUrl, + llmDrawingSvg, + gameState.drawingTimeLimit, + ]); + + useEffect(() => { + handleDrawingCompleteRef.current = handleDrawingComplete; + }, [handleDrawingComplete]); + + const generateLlmDrawing = useCallback(async () => { + if (!currentDrawer || currentDrawer.type !== "llm") return; + + setIsGeneratingDrawing(true); + setLlmDrawingSvg(null); + + try { + const response = await fetch("/api/generate-drawings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt: gameState.currentPrompt, + models: [currentDrawer.id], + }), + }); + + if (!response.ok) throw new Error("Failed to generate drawing"); + + const reader = response.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + try { + const event = JSON.parse(line.slice(6)); + if (event.type === "partial" && event.svg) { + setLlmDrawingSvg(event.svg); + } else if (event.type === "drawing") { + setLlmDrawingSvg(event.svg); + setGameState((prev) => ({ + ...prev, + totalCost: prev.totalCost + (event.cost || 0), + totalTokens: prev.totalTokens + (event.usage?.totalTokens || 0), + })); + } + } catch {} + } + } + + setIsGeneratingDrawing(false); + } catch (error) { + console.error("Error generating LLM drawing:", error); + setIsGeneratingDrawing(false); + } + }, [currentDrawer, gameState.currentPrompt]); + + useEffect(() => { + if (gameState.status === "drawing" && !isHumanDrawing) { + generateLlmDrawing(); + } + }, [gameState.status, isHumanDrawing, generateLlmDrawing]); + + useEffect(() => { + if ( + gameState.status === "drawing" && + !isHumanDrawing && + llmDrawingSvg && + !isGeneratingDrawing + ) { + setAutoTransitionCountdown(2); + autoTransitionTimerRef.current = setInterval(() => { + setAutoTransitionCountdown((prev) => { + if (prev === null || prev <= 1) { + if (autoTransitionTimerRef.current) + clearInterval(autoTransitionTimerRef.current); + handleDrawingCompleteRef.current(); + return null; + } + return prev - 1; + }); + }, 1000); + return () => { + if (autoTransitionTimerRef.current) + clearInterval(autoTransitionTimerRef.current); + setAutoTransitionCountdown(null); + }; + } + }, [gameState.status, isHumanDrawing, llmDrawingSvg, isGeneratingDrawing]); + + const expectedGuesserCount = useMemo(() => { + const llmGuessers = gameState.participants.filter( + (p) => p.id !== currentDrawer?.id && p.type === "llm", + ).length; + const humanIsGuesser = currentDrawer?.type !== "human"; + return llmGuessers + (humanIsGuesser ? 1 : 0); + }, [gameState.participants, currentDrawer]); + + const allGuessesReceived = useMemo(() => { + if (gameState.status !== "guessing") return false; + const receivedCount = Object.keys(llmGuesses).length; + const humanNeedsToGuess = currentDrawer?.type !== "human"; + const humanHasGuessed = humanNeedsToGuess ? humanGuessSubmitted : true; + return receivedCount >= expectedGuesserCount && humanHasGuessed; + }, [ + gameState.status, + llmGuesses, + expectedGuesserCount, + currentDrawer, + humanGuessSubmitted, + ]); + + const triggerLlmGuesses = useCallback( + async (imageSource: string) => { + const guessers = gameState.participants.filter( + (p) => p.id !== currentDrawer?.id && p.type === "llm", + ); + + if (guessers.length === 0) return; + + let imageDataUrl = imageSource; + if (imageSource.startsWith(" g.id), + }), + }); + + if (!response.ok) throw new Error("Failed to get guesses"); + + const reader = response.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + try { + const event = JSON.parse(line.slice(6)); + if (event.type === "guess") { + const timeMs = + event.generationTimeMs || + Date.now() - guessStartTimeRef.current; + scoreGuess(event.modelId, event.guess, timeMs, false); + } + } catch {} + } + } + } catch (error) { + console.error("Error getting LLM guesses:", error); + } + }, + [gameState.participants, currentDrawer], + ); + + const svgToPng = useCallback((svgString: string): Promise => { + return new Promise((resolve, reject) => { + const canvas = document.createElement("canvas"); + canvas.width = 400; + canvas.height = 400; + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("Could not get canvas context")); + return; + } + + const img = new Image(); + const svgBlob = new Blob([svgString], { + type: "image/svg+xml;charset=utf-8", + }); + const url = URL.createObjectURL(svgBlob); + + img.onload = () => { + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, 400, 400); + ctx.drawImage(img, 0, 0, 400, 400); + URL.revokeObjectURL(url); + resolve(canvas.toDataURL("image/png")); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("Failed to load SVG")); + }; + + img.src = url; + }); + }, []); + + const scoreGuess = useCallback( + async ( + participantId: string, + guess: string, + timeMs: number, + isHuman: boolean, + ) => { + try { + const response = await fetch("/api/score-guess", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + guess, + answer: gameState.currentPrompt, + timeMs, + isHumanGuesser: isHuman, + }), + }); + + if (!response.ok) throw new Error("Failed to score guess"); + + const result = await response.json(); + + const guessResult: GuessResult = { + participantId, + guess, + semanticScore: result.semanticScore, + timeBonus: result.timeBonus, + finalScore: result.finalScore, + timeMs, + }; + + setLlmGuesses((prev) => ({ + ...prev, + [participantId]: guessResult, + })); + } catch (error) { + console.error("Error scoring guess:", error); + } + }, + [gameState.currentPrompt], + ); + + const submitHumanGuess = useCallback(async () => { + if (!humanGuess.trim() || humanGuessSubmitted) return; + + setIsSubmittingGuess(true); + setHumanGuessSubmitted(true); + + const timeMs = Date.now() - guessStartTimeRef.current; + await scoreGuess("human", humanGuess.trim(), timeMs, true); + + setIsSubmittingGuess(false); + }, [humanGuess, humanGuessSubmitted, scoreGuess]); + + const finishGuessing = useCallback(async () => { + if (timerRef.current) clearInterval(timerRef.current); + + const allGuesses = Object.values(llmGuesses); + const guesserCount = gameState.participants.filter( + (p) => p.id !== currentDrawer?.id, + ).length; + + const roundResult: RoundResult = { + drawerId: currentDrawer?.id || "", + drawerType: currentDrawer?.type || "human", + prompt: gameState.currentPrompt, + svg: llmDrawingSvg || undefined, + imageDataUrl: isHumanDrawing ? drawingDataUrl : undefined, + guesses: allGuesses, + }; + + if (gameState.sessionId) { + try { + const response = await saveRound.mutateAsync({ + sessionId: gameState.sessionId, + roundNumber: gameState.currentRound, + drawerId: currentDrawer?.id || "", + drawerType: currentDrawer?.type || "human", + prompt: gameState.currentPrompt, + svg: llmDrawingSvg || undefined, + imageDataUrl: isHumanDrawing ? drawingDataUrl : undefined, + guesserCount, + drawing: + !isHumanDrawing && llmDrawingSvg + ? { + modelId: currentDrawer?.id || "", + svg: llmDrawingSvg, + } + : undefined, + guesses: allGuesses.map((g) => ({ + participantId: g.participantId, + participantType: + g.participantId === "human" + ? ("human" as const) + : ("llm" as const), + guess: g.guess, + semanticScore: g.semanticScore, + timeBonus: g.timeBonus, + finalScore: g.finalScore, + timeMs: g.timeMs, + })), + }); + roundResult.maxPossibleScore = response.maxPossibleScore; + roundResult.topScore = response.topScore; + } catch (error) { + console.error("Failed to save round:", error); + } + } + + setGameState((prev) => { + const updatedParticipants = prev.participants.map((p) => { + const guess = allGuesses.find((g) => g.participantId === p.id); + if (guess) { + return { + ...p, + score: p.score + guess.finalScore, + guessingScore: p.guessingScore + guess.semanticScore, + }; + } + if (p.id === currentDrawer?.id) { + const drawerBonus = allGuesses.reduce((sum, g) => { + const multiplier = g.participantId === "human" ? 1.5 : 1; + return sum + (g.semanticScore > 0.7 ? 10 * multiplier : 0); + }, 0); + return { + ...p, + score: p.score + drawerBonus, + drawingScore: p.drawingScore + (drawerBonus > 0 ? 1 : 0), + }; + } + return p; + }); + + return { + ...prev, + status: "round-results", + participants: updatedParticipants, + roundHistory: [...prev.roundHistory, roundResult], + }; + }); + }, [ + llmGuesses, + currentDrawer, + gameState.currentPrompt, + gameState.sessionId, + gameState.currentRound, + gameState.participants, + llmDrawingSvg, + isHumanDrawing, + drawingDataUrl, + saveRound, + ]); + + useEffect(() => { + finishGuessingRef.current = finishGuessing; + }, [finishGuessing]); + + useEffect(() => { + if (gameState.status === "guessing" && allGuessesReceived) { + if (finishGuessingCalledRef.current) return; + setAutoTransitionCountdown(3); + autoTransitionTimerRef.current = setInterval(() => { + setAutoTransitionCountdown((prev) => { + if (prev === null || prev <= 1) { + if (autoTransitionTimerRef.current) + clearInterval(autoTransitionTimerRef.current); + if (!finishGuessingCalledRef.current) { + finishGuessingCalledRef.current = true; + finishGuessingRef.current(); + } + return null; + } + return prev - 1; + }); + }, 1000); + return () => { + if (autoTransitionTimerRef.current) + clearInterval(autoTransitionTimerRef.current); + setAutoTransitionCountdown(null); + }; + } + }, [gameState.status, allGuessesReceived]); + + const nextRound = useCallback(() => { + const nextDrawerIndex = + (gameState.currentDrawerIndex + 1) % gameState.participants.length; + const isGameOver = gameState.currentRound >= gameState.totalRounds; + + if (isGameOver) { + saveSession.mutate({ + mode: "pictionary", + prompt: gameState.roundHistory.map((r) => r.prompt).join(", "), + totalCost: gameState.totalCost, + totalTokens: gameState.totalTokens, + drawings: gameState.roundHistory.map((r) => ({ + modelId: r.drawerId, + svg: r.svg || r.imageDataUrl || "", + })), + guesses: gameState.roundHistory.flatMap((r) => + r.guesses.map((g) => ({ + modelId: g.participantId, + guess: g.guess, + isCorrect: g.semanticScore > 0.7, + semanticScore: g.semanticScore, + timeBonus: g.timeBonus, + finalScore: g.finalScore, + isHuman: g.participantId === "human", + })), + ), + }); + setGameState((prev) => ({ + ...prev, + status: "game-over", + })); + } else { + const prompt = getRandomPrompt(); + setGameState((prev) => ({ + ...prev, + status: "drawing", + currentRound: prev.currentRound + 1, + currentDrawerIndex: nextDrawerIndex, + currentPrompt: prompt, + currentDrawing: null, + })); + setDrawingDataUrl(""); + setLlmDrawingSvg(null); + setHumanGuess(""); + setLlmGuesses({}); + setHumanGuessSubmitted(false); + finishGuessingCalledRef.current = false; + setAutoTransitionCountdown(null); + } + }, [ + gameState.currentDrawerIndex, + gameState.participants.length, + gameState.currentRound, + gameState.totalRounds, + gameState.roundHistory, + gameState.totalCost, + gameState.totalTokens, + saveSession, + ]); + + useEffect(() => { + nextRoundRef.current = nextRound; + }, [nextRound]); + + useEffect(() => { + if (gameState.status === "round-results") { + setAutoTransitionCountdown(3); + autoTransitionTimerRef.current = setInterval(() => { + setAutoTransitionCountdown((prev) => { + if (prev === null || prev <= 1) { + if (autoTransitionTimerRef.current) + clearInterval(autoTransitionTimerRef.current); + nextRoundRef.current(); + return null; + } + return prev - 1; + }); + }, 1000); + return () => { + if (autoTransitionTimerRef.current) + clearInterval(autoTransitionTimerRef.current); + setAutoTransitionCountdown(null); + }; + } + }, [gameState.status, gameState.currentRound]); + + const sortedParticipants = useMemo(() => { + return [...gameState.participants].sort((a, b) => b.score - a.score); + }, [gameState.participants]); + + return ( + +
+
+
+ + + +
+

Pictionary

+

+ You vs AI - Who draws & guesses best? +

+
+
+ {gameState.status !== "setup" && ( + <> + + + + + {formatCost(gameState.totalCost)} + + + +

Total cost

+

+ {gameState.totalTokens.toLocaleString()} tokens +

+
+
+ + + Round {gameState.currentRound}/{gameState.totalRounds} + + + )} +
+
+ + {gameState.status === "setup" && ( +
+
+ +

Choose Your Opponents

+

+ Pick 1-5 AI models to challenge. Everyone takes turns drawing + and guessing! +

+
+ + + +
+ {visionModels.map((model) => { + const isSelected = selectedModels.includes(model.id); + const isDisabled = + !isSelected && selectedModels.length >= 5; + return ( + + ); + })} +
+
+
+ +
+ + +
+
+ )} + + {gameState.status === "drawing" && ( +
+
+
+ + {currentDrawer?.type === "human" ? ( + + ) : ( + + )} + {currentDrawer?.name} is drawing + + + + {timeRemaining}s + +
+ {isHumanDrawing && ( + + Draw: “{gameState.currentPrompt}” + + )} + {!isHumanDrawing && ( +

+ Watch {currentDrawer?.name} draw... +

+ )} +
+ +
+ {isHumanDrawing ? ( + + ) : ( + +
+ {isGeneratingDrawing && !llmDrawingSvg && ( + + )} + {llmDrawingSvg && ( +
+ )} +
+ + )} + + {isHumanDrawing && ( + + )} + {!isHumanDrawing && + llmDrawingSvg && + !isGeneratingDrawing && + autoTransitionCountdown !== null && ( + + + Starting in {autoTransitionCountdown}s... + + )} +
+
+ )} + + {gameState.status === "guessing" && ( +
+
+ + + {timeRemaining}s to guess + + {!isHumanDrawing && ( +

+ What did {currentDrawer?.name} draw? +

+ )} +
+ +
+ +
+ {isHumanDrawing && drawingDataUrl && ( + Drawing + )} + {!isHumanDrawing && llmDrawingSvg && ( +
+ )} +
+ + +
+ {currentDrawer?.type !== "human" && ( + + +
+
+ Your Guess +
+ {!humanGuessSubmitted ? ( +
+ setHumanGuess(e.target.value)} + placeholder="Type your guess..." + onKeyDown={(e) => + e.key === "Enter" && submitHumanGuess() + } + disabled={isSubmittingGuess} + /> + +
+ ) : ( +
+ “{humanGuess}” + {llmGuesses.human && ( + + ( + {Math.round( + llmGuesses.human.semanticScore * 100, + )} + % match) + + )} +
+ )} + + + )} + +
+ {gameState.participants + .filter( + (p) => p.id !== currentDrawer?.id && p.type === "llm", + ) + .map((participant) => { + const guess = llmGuesses[participant.id]; + const canShowGuess = + humanGuessSubmitted || isHumanDrawing; + return ( + 0.7 && + "ring-2 ring-green-500", + )} + > + +
+
+ + {participant.name} + + {!guess && ( + + )} + {guess && !canShowGuess && ( + + Ready + + )} + {guess && canShowGuess && ( + + {Math.round(guess.semanticScore * 100)}% + + )} +
+ {guess && canShowGuess && ( +
+ “{guess.guess}” +
+ )} + + + ); + })} +
+ + {allGuessesReceived && autoTransitionCountdown !== null && ( + + + Round ending in {autoTransitionCountdown}s... + + )} +
+
+
+ )} + + {gameState.status === "round-results" && ( +
+
+ + Answer: “{gameState.currentPrompt}” + +

+ Round {gameState.currentRound} Results +

+ {gameState.roundHistory[gameState.roundHistory.length - 1] + ?.maxPossibleScore && ( +

+ Top score:{" "} + { + gameState.roundHistory[gameState.roundHistory.length - 1] + .topScore + } + / + { + gameState.roundHistory[gameState.roundHistory.length - 1] + .maxPossibleScore + }{" "} + ( + {Math.round( + ((gameState.roundHistory[ + gameState.roundHistory.length - 1 + ].topScore ?? 0) / + (gameState.roundHistory[ + gameState.roundHistory.length - 1 + ].maxPossibleScore ?? 1)) * + 100, + )} + % efficiency) +

+ )} +
+ +
+ +
+ {gameState.roundHistory[gameState.roundHistory.length - 1] + ?.imageDataUrl && ( + Drawing + )} + {gameState.roundHistory[gameState.roundHistory.length - 1] + ?.svg && ( +
+ )} +
+ + +
+ {Object.values(llmGuesses) + .sort((a, b) => b.finalScore - a.finalScore) + .map((guess) => { + const participant = gameState.participants.find( + (p) => p.id === guess.participantId, + ); + if (!participant) return null; + return ( + 0.7 && + "ring-2 ring-green-500 bg-green-500/5", + )} + > + +
+
+
+ + {participant.name} + +
+ + +{guess.finalScore} pts + +
+
+ “{guess.guess}” +
+
+ {Math.round(guess.semanticScore * 100)}% match + {guess.timeBonus > 0 && + ` • +${guess.timeBonus} time bonus`} +
+ + + ); + })} +
+
+ + + +
+ + Standings +
+
+ {sortedParticipants.map((p, index) => ( +
+ + {index + 1} + +
+ {p.name} + {p.score} pts +
+ ))} +
+ + + + {autoTransitionCountdown !== null && ( +
+ + + {gameState.currentRound >= gameState.totalRounds + ? `Final results in ${autoTransitionCountdown}s...` + : `Next round in ${autoTransitionCountdown}s...`} + +
+ )} +
+ )} + + {gameState.status === "game-over" && ( +
+
+ +

+ {sortedParticipants[0]?.id === "human" + ? "You Won!" + : "Game Complete!"} +

+
+ + {sortedParticipants[0] && ( + + +
+

+ {sortedParticipants[0].name} +

+

+ {sortedParticipants[0].id === "human" + ? `You scored ${sortedParticipants[0].score} points!` + : `Champion with ${sortedParticipants[0].score} points`} +

+ + + )} + + + +

Final Standings

+
+ {sortedParticipants.map((p, index) => ( +
+ + {index + 1} + +
+ {p.name} +
+
{p.score} pts
+
+ Draw: {p.drawingScore.toFixed(1)} • Guess:{" "} + {p.guessingScore.toFixed(1)} +
+
+
+ ))} +
+ + + +
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/bun.lock b/bun.lock index 23d7735..cb91807 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "pintel", diff --git a/components/game-examples.tsx b/components/game-examples.tsx new file mode 100644 index 0000000..780b9b3 --- /dev/null +++ b/components/game-examples.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { ArrowRight, Sparkles, User } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { useGallery } from "@/lib/hooks/use-gallery"; +import { getModelById } from "@/lib/models"; + +function AnimatedDrawingCard({ + drawing, + prompt, + delay = 0, +}: { + drawing: { + svg: string; + chunks?: string[] | null; + modelId: string; + isHumanDrawing?: boolean; + }; + prompt: string; + delay?: number; +}) { + const [currentChunkIndex, setCurrentChunkIndex] = useState(0); + const [started, setStarted] = useState(false); + const intervalRef = useRef(null); + + const chunks = drawing.chunks || []; + const hasReplay = chunks.length > 0; + const allFrames = hasReplay ? [...chunks, drawing.svg] : [drawing.svg]; + const currentSvg = allFrames[currentChunkIndex] || drawing.svg; + const model = getModelById(drawing.modelId); + const isHumanDrawing = drawing.isHumanDrawing || drawing.modelId === "human"; + const isImageDataUrl = currentSvg?.startsWith("data:image"); + + useEffect(() => { + const startTimer = setTimeout(() => { + setStarted(true); + }, delay); + return () => clearTimeout(startTimer); + }, [delay]); + + useEffect(() => { + if (!started || !hasReplay) return; + + intervalRef.current = setInterval(() => { + setCurrentChunkIndex((prev) => { + if (prev >= allFrames.length - 1) { + setTimeout(() => { + setCurrentChunkIndex(0); + }, 2000); + return prev; + } + return prev + 1; + }); + }, 50); + + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [started, hasReplay, allFrames.length]); + + return ( + + +
+ {isImageDataUrl ? ( + {prompt} + ) : ( +
+ )} + {isHumanDrawing ? ( +
+ +
+ ) : model ? ( +
+ ) : null} + {hasReplay && ( +
+
+
+ )} +
+
+

+ “{prompt}” +

+
+ + + ); +} + +export function GameExamples() { + const { data, isLoading } = useGallery("pictionary", 1, 6); + + if (isLoading) { + return ( +
+
+ + Recent Games +
+
+ {[...Array(3)].map((_, i) => ( + + +
+
+
+
+ + + ))} +
+
+ ); + } + + if (!data || data.items.length === 0) { + return null; + } + + const exampleItems = data.items + .filter((item) => item.drawings.length > 0) + .slice(0, 3); + + if (exampleItems.length === 0) return null; + + return ( +
+
+ + Watch AI Draw Live +
+
+ {exampleItems.map((item, idx) => { + const drawing = item.drawings[0]; + if (!drawing) return null; + return ( + + + + ); + })} +
+
+ +
+
+ ); +} diff --git a/components/game-mode-card.tsx b/components/game-mode-card.tsx deleted file mode 100644 index 243edff..0000000 --- a/components/game-mode-card.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { ArrowRight, type LucideIcon } from "lucide-react"; -import Link from "next/link"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; -import { cn } from "@/lib/utils"; - -type GameMode = { - id: string; - title: string; - description: string; - icon: LucideIcon; - href: string; - available: boolean; -}; - -export function GameModeCard({ mode }: { mode: GameMode }) { - const Icon = mode.icon; - - return ( - - - -
- -
-
-
-

{mode.title}

- {!mode.available && ( - - Coming soon - - )} -
-

{mode.description}

-
- {mode.available && ( - - )} -
-
- - ); -} diff --git a/components/inline-leaderboard.tsx b/components/inline-leaderboard.tsx new file mode 100644 index 0000000..a155037 --- /dev/null +++ b/components/inline-leaderboard.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { ArrowRight, Bot, Eye, Palette, Trophy, User } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useLeaderboard } from "@/lib/hooks/use-leaderboard"; +import { getModelById } from "@/lib/models"; +import { cn } from "@/lib/utils"; + +export function InlineLeaderboard() { + const { data, isLoading, error } = useLeaderboard("combined"); + + if (isLoading) { + return ( + + +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+
+
+ ); + } + + if (error || !data) { + return null; + } + + const topEntries = data.entries.slice(0, 5); + + if (topEntries.length === 0) { + return ( + + + No scores yet. Be the first to play! + + + ); + } + + return ( + + +
+
+ +

Top Performers

+
+ + + +
+
+
+
#
+
Competitor
+
+ + Draw +
+
+ + Guess +
+
Score
+
+ {topEntries.map((entry, index) => { + const modelInfo = + entry.type === "llm" && entry.modelId + ? getModelById(entry.modelId) + : null; + const color = modelInfo?.color || "#10b981"; + + return ( +
+
+ + {index + 1} + +
+
+
+ + {entry.name} + + {entry.type === "llm" ? ( + + ) : ( + + )} +
+
+ + {entry.drawingScore.toFixed(1)} + +
+
+ + {entry.guessingScore.toFixed(1)} + +
+
+ + {entry.overallScore.toFixed(1)} + +
+
+ ); + })} +
+ {data.totalSessions > 0 && ( +
+ Based on {data.totalSessions} games played +
+ )} + + + ); +} diff --git a/components/logos/pintel-theme-aware.tsx b/components/logos/pintel-theme-aware.tsx index 22ad1ae..76ad27f 100644 --- a/components/logos/pintel-theme-aware.tsx +++ b/components/logos/pintel-theme-aware.tsx @@ -4,11 +4,7 @@ import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; import { PintelLogo } from "./pintel"; -export function PintelLogoThemeAware({ - className, -}: { - className?: string; -}) { +export function PintelLogoThemeAware({ className }: { className?: string }) { const { resolvedTheme } = useTheme(); const [mounted, setMounted] = useState(false); @@ -20,4 +16,3 @@ export function PintelLogoThemeAware({ return ; } - diff --git a/components/logos/pintel.tsx b/components/logos/pintel.tsx index 144331c..d46fef7 100644 --- a/components/logos/pintel.tsx +++ b/components/logos/pintel.tsx @@ -32,4 +32,3 @@ export function PintelLogo({ mode = "light", ...props }: PintelLogoProps) { ); } - diff --git a/components/site-header.tsx b/components/site-header.tsx index 500f7fc..1a98321 100644 --- a/components/site-header.tsx +++ b/components/site-header.tsx @@ -3,8 +3,8 @@ import Link from "next/link"; import { AuthButtons } from "@/components/auth-buttons"; import { DesktopNav } from "@/components/desktop-nav"; -import { MobileNav } from "@/components/mobile-nav"; import { PintelLogoThemeAware } from "@/components/logos/pintel-theme-aware"; +import { MobileNav } from "@/components/mobile-nav"; import { ToggleTheme } from "@/components/toggle-theme"; import { MAIN_NAV } from "@/config/site"; import { cn } from "@/lib/utils"; diff --git a/db/schema.ts b/db/schema.ts index f44f0a9..710308c 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -10,23 +10,62 @@ import { } from "drizzle-orm/pg-core"; import { z } from "zod"; -// Zod schemas for type safety -export const GameModeSchema = z.enum(["human_judge", "model_guess", "ai_duel"]); +export const GameModeSchema = z.enum([ + "pictionary", + "human_judge", + "model_guess", + "ai_duel", +]); export const gameSessions = pgTable("game_sessions", { id: uuid("id").primaryKey().defaultRandom(), mode: text("mode", { - enum: ["human_judge", "model_guess", "ai_duel"], + enum: ["pictionary", "human_judge", "model_guess", "ai_duel"], }).notNull(), prompt: text("prompt").notNull(), totalCost: real("total_cost").default(0), totalTokens: integer("total_tokens").default(0), totalTimeMs: integer("total_time_ms"), + currentRound: integer("current_round").default(1), + totalRounds: integer("total_rounds").default(1), anonId: text("anon_id"), clerkUserId: text("clerk_user_id"), createdAt: timestamp("created_at").defaultNow().notNull(), }); +export const playerScores = pgTable("player_scores", { + id: uuid("id").primaryKey().defaultRandom(), + clerkUserId: text("clerk_user_id").unique(), + anonId: text("anon_id").unique(), + username: text("username"), + totalScore: integer("total_score").default(0), + gamesPlayed: integer("games_played").default(0), + gamesWon: integer("games_won").default(0), + roundsPlayed: integer("rounds_played").default(0), + drawingRounds: integer("drawing_rounds").default(0), + guessingRounds: integer("guessing_rounds").default(0), + drawingScore: real("drawing_score").default(0), + guessingScore: real("guessing_score").default(0), + bestRoundScore: integer("best_round_score").default(0), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow(), +}); + +export const roundResults = pgTable("round_results", { + id: uuid("id").primaryKey().defaultRandom(), + sessionId: uuid("session_id") + .references(() => gameSessions.id, { onDelete: "cascade" }) + .notNull(), + roundNumber: integer("round_number").notNull(), + drawerId: text("drawer_id").notNull(), + drawerType: text("drawer_type", { enum: ["human", "llm"] }).notNull(), + prompt: text("prompt").notNull(), + maxPossibleScore: integer("max_possible_score").notNull(), + topScore: integer("top_score").default(0), + svg: text("svg"), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + export const drawings = pgTable("drawings", { id: uuid("id").primaryKey().defaultRandom(), sessionId: uuid("session_id") @@ -38,7 +77,7 @@ export const drawings = pgTable("drawings", { cost: real("cost"), tokens: integer("tokens"), isWinner: boolean("is_winner").default(false), - chunks: text("chunks"), // JSON array of partial SVGs for replay + chunks: text("chunks"), }); export const guesses = pgTable("guesses", { @@ -46,9 +85,16 @@ export const guesses = pgTable("guesses", { sessionId: uuid("session_id") .references(() => gameSessions.id, { onDelete: "cascade" }) .notNull(), + roundId: uuid("round_id").references(() => roundResults.id, { + onDelete: "cascade", + }), modelId: text("model_id").notNull(), guess: text("guess").notNull(), isCorrect: boolean("is_correct").default(false), + semanticScore: real("semantic_score"), + timeBonus: integer("time_bonus").default(0), + finalScore: integer("final_score").default(0), + isHuman: boolean("is_human").default(false), generationTimeMs: integer("generation_time_ms"), cost: real("cost"), tokens: integer("tokens"), @@ -56,6 +102,10 @@ export const guesses = pgTable("guesses", { export const modelScores = pgTable("model_scores", { modelId: text("model_id").primaryKey(), + drawingScore: real("drawing_score").default(0), + guessingScore: real("guessing_score").default(0), + drawingRounds: integer("drawing_rounds").default(0), + guessingRounds: integer("guessing_rounds").default(0), humanJudgeWins: integer("human_judge_wins").default(0), humanJudgePlays: integer("human_judge_plays").default(0), modelGuessCorrect: integer("model_guess_correct").default(0), @@ -67,10 +117,17 @@ export const modelScores = pgTable("model_scores", { updatedAt: timestamp("updated_at").defaultNow(), }); -// Relations export const gameSessionsRelations = relations(gameSessions, ({ many }) => ({ drawings: many(drawings), guesses: many(guesses), + rounds: many(roundResults), +})); + +export const roundResultsRelations = relations(roundResults, ({ one }) => ({ + session: one(gameSessions, { + fields: [roundResults.sessionId], + references: [gameSessions.id], + }), })); export const drawingsRelations = relations(drawings, ({ one }) => ({ @@ -85,9 +142,12 @@ export const guessesRelations = relations(guesses, ({ one }) => ({ fields: [guesses.sessionId], references: [gameSessions.id], }), + round: one(roundResults, { + fields: [guesses.roundId], + references: [roundResults.id], + }), })); -// Type exports export type GameSession = typeof gameSessions.$inferSelect; export type NewGameSession = typeof gameSessions.$inferInsert; export type Drawing = typeof drawings.$inferSelect; @@ -96,3 +156,7 @@ export type Guess = typeof guesses.$inferSelect; export type NewGuess = typeof guesses.$inferInsert; export type ModelScore = typeof modelScores.$inferSelect; export type NewModelScore = typeof modelScores.$inferInsert; +export type PlayerScore = typeof playerScores.$inferSelect; +export type NewPlayerScore = typeof playerScores.$inferInsert; +export type RoundResult = typeof roundResults.$inferSelect; +export type NewRoundResult = typeof roundResults.$inferInsert; diff --git a/drizzle/0000_panoramic_ghost_rider.sql b/drizzle/0000_panoramic_ghost_rider.sql new file mode 100644 index 0000000..92b1277 --- /dev/null +++ b/drizzle/0000_panoramic_ghost_rider.sql @@ -0,0 +1,100 @@ +-- Current sql file was generated after introspecting the database +-- If you want to run this migration please uncomment this code before executing migrations +/* +CREATE TABLE "model_scores" ( + "model_id" text PRIMARY KEY NOT NULL, + "drawing_score" real DEFAULT 0, + "guessing_score" real DEFAULT 0, + "drawing_rounds" integer DEFAULT 0, + "guessing_rounds" integer DEFAULT 0, + "human_judge_wins" integer DEFAULT 0, + "human_judge_plays" integer DEFAULT 0, + "model_guess_correct" integer DEFAULT 0, + "model_guess_total" integer DEFAULT 0, + "ai_duel_points" integer DEFAULT 0, + "ai_duel_rounds" integer DEFAULT 0, + "total_cost" real DEFAULT 0, + "total_tokens" integer DEFAULT 0, + "updated_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "game_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "mode" text NOT NULL, + "prompt" text NOT NULL, + "total_cost" real DEFAULT 0, + "total_tokens" integer DEFAULT 0, + "total_time_ms" integer, + "current_round" integer DEFAULT 1, + "total_rounds" integer DEFAULT 1, + "anon_id" text, + "clerk_user_id" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "drawings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" uuid NOT NULL, + "model_id" text NOT NULL, + "svg" text NOT NULL, + "generation_time_ms" integer, + "cost" real, + "tokens" integer, + "is_winner" boolean DEFAULT false, + "chunks" text +); +--> statement-breakpoint +CREATE TABLE "guesses" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" uuid NOT NULL, + "model_id" text NOT NULL, + "guess" text NOT NULL, + "is_correct" boolean DEFAULT false, + "semantic_score" real, + "time_bonus" integer DEFAULT 0, + "final_score" integer DEFAULT 0, + "is_human" boolean DEFAULT false, + "generation_time_ms" integer, + "cost" real, + "tokens" integer, + "round_id" uuid +); +--> statement-breakpoint +CREATE TABLE "player_scores" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "clerk_user_id" text, + "anon_id" text, + "username" text, + "total_score" integer DEFAULT 0, + "games_played" integer DEFAULT 0, + "games_won" integer DEFAULT 0, + "rounds_played" integer DEFAULT 0, + "drawing_rounds" integer DEFAULT 0, + "guessing_rounds" integer DEFAULT 0, + "drawing_score" real DEFAULT 0, + "guessing_score" real DEFAULT 0, + "best_round_score" integer DEFAULT 0, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now(), + CONSTRAINT "player_scores_clerk_user_id_unique" UNIQUE("clerk_user_id"), + CONSTRAINT "player_scores_anon_id_unique" UNIQUE("anon_id") +); +--> statement-breakpoint +CREATE TABLE "round_results" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" uuid NOT NULL, + "round_number" integer NOT NULL, + "drawer_id" text NOT NULL, + "drawer_type" text NOT NULL, + "prompt" text NOT NULL, + "max_possible_score" integer NOT NULL, + "top_score" integer DEFAULT 0, + "svg" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "drawings" ADD CONSTRAINT "drawings_session_id_game_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."game_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "guesses" ADD CONSTRAINT "guesses_session_id_game_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."game_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "guesses" ADD CONSTRAINT "guesses_round_id_round_results_id_fk" FOREIGN KEY ("round_id") REFERENCES "public"."round_results"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "round_results" ADD CONSTRAINT "round_results_session_id_game_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."game_sessions"("id") ON DELETE cascade ON UPDATE no action; +*/ \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..2d5ef5d --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,627 @@ +{ + "id": "00000000-0000-0000-0000-000000000000", + "prevId": "", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.model_scores": { + "name": "model_scores", + "schema": "", + "columns": { + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drawing_score": { + "name": "drawing_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "guessing_score": { + "name": "guessing_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "drawing_rounds": { + "name": "drawing_rounds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "guessing_rounds": { + "name": "guessing_rounds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "human_judge_wins": { + "name": "human_judge_wins", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "human_judge_plays": { + "name": "human_judge_plays", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "model_guess_correct": { + "name": "model_guess_correct", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "model_guess_total": { + "name": "model_guess_total", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "ai_duel_points": { + "name": "ai_duel_points", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "ai_duel_rounds": { + "name": "ai_duel_rounds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {}, + "policies": {}, + "isRLSEnabled": false + }, + "public.game_sessions": { + "name": "game_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_cost": { + "name": "total_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_time_ms": { + "name": "total_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_round": { + "name": "current_round", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "total_rounds": { + "name": "total_rounds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "anon_id": { + "name": "anon_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "clerk_user_id": { + "name": "clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {}, + "policies": {}, + "isRLSEnabled": false + }, + "public.drawings": { + "name": "drawings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "svg": { + "name": "svg", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation_time_ms": { + "name": "generation_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_winner": { + "name": "is_winner", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "chunks": { + "name": "chunks", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "drawings_session_id_game_sessions_id_fk": { + "name": "drawings_session_id_game_sessions_id_fk", + "tableFrom": "drawings", + "tableTo": "game_sessions", + "schemaTo": "public", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {}, + "policies": {}, + "isRLSEnabled": false + }, + "public.guesses": { + "name": "guesses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "guess": { + "name": "guess", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "semantic_score": { + "name": "semantic_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "time_bonus": { + "name": "time_bonus", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "final_score": { + "name": "final_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "is_human": { + "name": "is_human", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "generation_time_ms": { + "name": "generation_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "round_id": { + "name": "round_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "guesses_session_id_game_sessions_id_fk": { + "name": "guesses_session_id_game_sessions_id_fk", + "tableFrom": "guesses", + "tableTo": "game_sessions", + "schemaTo": "public", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "guesses_round_id_round_results_id_fk": { + "name": "guesses_round_id_round_results_id_fk", + "tableFrom": "guesses", + "tableTo": "round_results", + "schemaTo": "public", + "columnsFrom": ["round_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {}, + "policies": {}, + "isRLSEnabled": false + }, + "public.player_scores": { + "name": "player_scores", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "clerk_user_id": { + "name": "clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "anon_id": { + "name": "anon_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_score": { + "name": "total_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "games_played": { + "name": "games_played", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "games_won": { + "name": "games_won", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rounds_played": { + "name": "rounds_played", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "drawing_rounds": { + "name": "drawing_rounds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "guessing_rounds": { + "name": "guessing_rounds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "drawing_score": { + "name": "drawing_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "guessing_score": { + "name": "guessing_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "best_round_score": { + "name": "best_round_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "player_scores_clerk_user_id_unique": { + "columns": ["clerk_user_id"], + "nullsNotDistinct": false, + "name": "player_scores_clerk_user_id_unique" + }, + "player_scores_anon_id_unique": { + "columns": ["anon_id"], + "nullsNotDistinct": false, + "name": "player_scores_anon_id_unique" + } + }, + "checkConstraints": {}, + "policies": {}, + "isRLSEnabled": false + }, + "public.round_results": { + "name": "round_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "round_number": { + "name": "round_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "drawer_id": { + "name": "drawer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "drawer_type": { + "name": "drawer_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "max_possible_score": { + "name": "max_possible_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "top_score": { + "name": "top_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "svg": { + "name": "svg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "round_results_session_id_game_sessions_id_fk": { + "name": "round_results_session_id_game_sessions_id_fk", + "tableFrom": "round_results", + "tableTo": "game_sessions", + "schemaTo": "public", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {}, + "policies": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..fa219b3 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1765559430182, + "tag": "0000_panoramic_ghost_rider", + "breakpoints": true + } + ] +} diff --git a/drizzle/relations.ts b/drizzle/relations.ts new file mode 100644 index 0000000..99814fd --- /dev/null +++ b/drizzle/relations.ts @@ -0,0 +1,37 @@ +import { relations } from "drizzle-orm/relations"; +import { drawings, gameSessions, guesses, roundResults } from "./schema"; + +export const drawingsRelations = relations(drawings, ({ one }) => ({ + gameSession: one(gameSessions, { + fields: [drawings.sessionId], + references: [gameSessions.id], + }), +})); + +export const gameSessionsRelations = relations(gameSessions, ({ many }) => ({ + drawings: many(drawings), + guesses: many(guesses), + roundResults: many(roundResults), +})); + +export const guessesRelations = relations(guesses, ({ one }) => ({ + gameSession: one(gameSessions, { + fields: [guesses.sessionId], + references: [gameSessions.id], + }), + roundResult: one(roundResults, { + fields: [guesses.roundId], + references: [roundResults.id], + }), +})); + +export const roundResultsRelations = relations( + roundResults, + ({ one, many }) => ({ + guesses: many(guesses), + gameSession: one(gameSessions, { + fields: [roundResults.sessionId], + references: [gameSessions.id], + }), + }), +); diff --git a/drizzle/schema.ts b/drizzle/schema.ts new file mode 100644 index 0000000..7684f93 --- /dev/null +++ b/drizzle/schema.ts @@ -0,0 +1,148 @@ +import { sql } from "drizzle-orm"; +import { + boolean, + foreignKey, + integer, + pgTable, + real, + text, + timestamp, + unique, + uuid, +} from "drizzle-orm/pg-core"; + +export const modelScores = pgTable("model_scores", { + modelId: text("model_id").primaryKey().notNull(), + drawingScore: real("drawing_score").default(0), + guessingScore: real("guessing_score").default(0), + drawingRounds: integer("drawing_rounds").default(0), + guessingRounds: integer("guessing_rounds").default(0), + humanJudgeWins: integer("human_judge_wins").default(0), + humanJudgePlays: integer("human_judge_plays").default(0), + modelGuessCorrect: integer("model_guess_correct").default(0), + modelGuessTotal: integer("model_guess_total").default(0), + aiDuelPoints: integer("ai_duel_points").default(0), + aiDuelRounds: integer("ai_duel_rounds").default(0), + totalCost: real("total_cost").default(0), + totalTokens: integer("total_tokens").default(0), + updatedAt: timestamp("updated_at", { mode: "string" }).defaultNow(), +}); + +export const gameSessions = pgTable("game_sessions", { + id: uuid().defaultRandom().primaryKey().notNull(), + mode: text().notNull(), + prompt: text().notNull(), + totalCost: real("total_cost").default(0), + totalTokens: integer("total_tokens").default(0), + totalTimeMs: integer("total_time_ms"), + currentRound: integer("current_round").default(1), + totalRounds: integer("total_rounds").default(1), + anonId: text("anon_id"), + clerkUserId: text("clerk_user_id"), + createdAt: timestamp("created_at", { mode: "string" }).defaultNow().notNull(), +}); + +export const drawings = pgTable( + "drawings", + { + id: uuid().defaultRandom().primaryKey().notNull(), + sessionId: uuid("session_id").notNull(), + modelId: text("model_id").notNull(), + svg: text().notNull(), + generationTimeMs: integer("generation_time_ms"), + cost: real(), + tokens: integer(), + isWinner: boolean("is_winner").default(false), + chunks: text(), + }, + (table) => [ + foreignKey({ + columns: [table.sessionId], + foreignColumns: [gameSessions.id], + name: "drawings_session_id_game_sessions_id_fk", + }).onDelete("cascade"), + ], +); + +export const guesses = pgTable( + "guesses", + { + id: uuid().defaultRandom().primaryKey().notNull(), + sessionId: uuid("session_id").notNull(), + modelId: text("model_id").notNull(), + guess: text().notNull(), + isCorrect: boolean("is_correct").default(false), + semanticScore: real("semantic_score"), + timeBonus: integer("time_bonus").default(0), + finalScore: integer("final_score").default(0), + isHuman: boolean("is_human").default(false), + generationTimeMs: integer("generation_time_ms"), + cost: real(), + tokens: integer(), + roundId: uuid("round_id"), + }, + (table) => [ + foreignKey({ + columns: [table.sessionId], + foreignColumns: [gameSessions.id], + name: "guesses_session_id_game_sessions_id_fk", + }).onDelete("cascade"), + foreignKey({ + columns: [table.roundId], + foreignColumns: [roundResults.id], + name: "guesses_round_id_round_results_id_fk", + }).onDelete("cascade"), + ], +); + +export const playerScores = pgTable( + "player_scores", + { + id: uuid().defaultRandom().primaryKey().notNull(), + clerkUserId: text("clerk_user_id"), + anonId: text("anon_id"), + username: text(), + totalScore: integer("total_score").default(0), + gamesPlayed: integer("games_played").default(0), + gamesWon: integer("games_won").default(0), + roundsPlayed: integer("rounds_played").default(0), + drawingRounds: integer("drawing_rounds").default(0), + guessingRounds: integer("guessing_rounds").default(0), + drawingScore: real("drawing_score").default(0), + guessingScore: real("guessing_score").default(0), + bestRoundScore: integer("best_round_score").default(0), + createdAt: timestamp("created_at", { mode: "string" }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { mode: "string" }).defaultNow(), + }, + (table) => [ + unique("player_scores_clerk_user_id_unique").on(table.clerkUserId), + unique("player_scores_anon_id_unique").on(table.anonId), + ], +); + +export const roundResults = pgTable( + "round_results", + { + id: uuid().defaultRandom().primaryKey().notNull(), + sessionId: uuid("session_id").notNull(), + roundNumber: integer("round_number").notNull(), + drawerId: text("drawer_id").notNull(), + drawerType: text("drawer_type").notNull(), + prompt: text().notNull(), + maxPossibleScore: integer("max_possible_score").notNull(), + topScore: integer("top_score").default(0), + svg: text(), + createdAt: timestamp("created_at", { mode: "string" }) + .defaultNow() + .notNull(), + }, + (table) => [ + foreignKey({ + columns: [table.sessionId], + foreignColumns: [gameSessions.id], + name: "round_results_session_id_game_sessions_id_fk", + }).onDelete("cascade"), + ], +); diff --git a/lib/hooks/use-gallery.ts b/lib/hooks/use-gallery.ts index 2aa7ec1..1c680fa 100644 --- a/lib/hooks/use-gallery.ts +++ b/lib/hooks/use-gallery.ts @@ -1,7 +1,29 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useUserIdentity } from "./use-user-identity"; -export type GameMode = "human_judge" | "model_guess" | "ai_duel"; +export type GameMode = "pictionary" | "human_judge" | "model_guess" | "ai_duel"; + +export interface RoundGuess { + id: string; + modelId: string; + guess: string; + isCorrect: boolean; + isHuman: boolean | null; + semanticScore: number | null; + finalScore: number | null; + generationTimeMs: number | null; +} + +export interface GameRound { + id: string; + roundNumber: number; + prompt: string; + drawerType: "human" | "llm"; + drawerId: string; + svg: string | null; + chunks: string[] | null; + guesses: RoundGuess[]; +} export interface GalleryItem { id: string; @@ -11,6 +33,8 @@ export interface GalleryItem { totalTokens: number; totalTimeMs: number | null; createdAt: string; + playerName?: string | null; + rounds?: GameRound[]; drawings: Array<{ id: string; modelId: string; @@ -19,6 +43,7 @@ export interface GalleryItem { cost: number | null; isWinner: boolean; chunks?: string[] | null; + isHumanDrawing?: boolean; }>; guesses: Array<{ id: string; @@ -81,7 +106,7 @@ export function useGallery(mode?: GameMode, page = 1, pageSize = 20) { export function useSaveSession() { const queryClient = useQueryClient(); - const { anonId } = useUserIdentity(); + useUserIdentity(); return useMutation({ mutationFn: async (data: SaveSessionData) => { @@ -111,3 +136,84 @@ export function useSessionDetail(id: string) { enabled: !!id, }); } + +export interface SaveRoundData { + sessionId: string; + roundNumber: number; + drawerId: string; + drawerType: "human" | "llm"; + prompt: string; + svg?: string; + imageDataUrl?: string; + guesserCount: number; + drawing?: { + modelId: string; + svg: string; + generationTimeMs?: number; + cost?: number; + tokens?: number; + }; + guesses: Array<{ + participantId: string; + participantType: "human" | "llm"; + guess: string; + semanticScore: number; + timeBonus: number; + finalScore: number; + timeMs: number; + cost?: number; + tokens?: number; + }>; +} + +export interface SaveRoundResponse { + id: string; + maxPossibleScore: number; + topScore: number; +} + +export function useSaveRound() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: SaveRoundData): Promise => { + const res = await fetch("/api/rounds", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error("Failed to save round"); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["leaderboard"] }); + }, + }); +} + +export interface CreateSessionData { + mode: "pictionary" | "human_judge" | "model_guess" | "ai_duel"; + prompt: string; + totalRounds: number; + participants: string[]; +} + +export interface CreateSessionResponse { + sessionId: string; +} + +export function useCreateSession() { + return useMutation({ + mutationFn: async ( + data: CreateSessionData, + ): Promise => { + const res = await fetch("/api/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error("Failed to create session"); + return res.json(); + }, + }); +} diff --git a/lib/hooks/use-leaderboard.ts b/lib/hooks/use-leaderboard.ts index 719694a..2994266 100644 --- a/lib/hooks/use-leaderboard.ts +++ b/lib/hooks/use-leaderboard.ts @@ -1,30 +1,49 @@ import { useQuery } from "@tanstack/react-query"; -export interface ModelScore { - modelId: string; - humanJudgeWins: number; - humanJudgePlays: number; - humanJudgeWinRate: number; - modelGuessCorrect: number; - modelGuessTotal: number; - modelGuessAccuracy: number; - aiDuelPoints: number; - aiDuelRounds: number; +export type LeaderboardType = "combined" | "llm" | "human"; + +export interface LeaderboardEntry { + id: string; + type: "llm" | "human"; + name: string; + modelId?: string; + clerkUserId?: string | null; + anonId?: string | null; + drawingScore: number; + guessingScore: number; + drawingRounds: number; + guessingRounds: number; + overallScore: number; + totalScore: number; + roundsPlayed: number; + gamesPlayed: number; + gamesWon: number; + bestRoundScore: number; totalCost: number; totalTokens: number; - overallScore: number; + humanJudgeWins?: number; + humanJudgePlays?: number; + humanJudgeWinRate?: number; + modelGuessCorrect?: number; + modelGuessTotal?: number; + modelGuessAccuracy?: number; + aiDuelPoints?: number; + aiDuelRounds?: number; } export interface LeaderboardResponse { - models: ModelScore[]; + entries: LeaderboardEntry[]; + models: LeaderboardEntry[]; + players: LeaderboardEntry[]; totalSessions: number; + type: LeaderboardType; } -export function useLeaderboard() { +export function useLeaderboard(type: LeaderboardType = "combined") { return useQuery({ - queryKey: ["leaderboard"], + queryKey: ["leaderboard", type], queryFn: async (): Promise => { - const res = await fetch("/api/leaderboard"); + const res = await fetch(`/api/leaderboard?type=${type}`); if (!res.ok) throw new Error("Failed to fetch leaderboard"); return res.json(); }, diff --git a/lib/identity.ts b/lib/identity.ts index 13f4581..8fdf94e 100644 --- a/lib/identity.ts +++ b/lib/identity.ts @@ -1,10 +1,11 @@ -import { auth } from "@clerk/nextjs/server"; +import { auth, currentUser } from "@clerk/nextjs/server"; import type { NextRequest } from "next/server"; export interface UserIdentity { anonId: string | null; clerkUserId: string | null; isAuthenticated: boolean; + username: string | null; } export async function getCurrentIdentity( @@ -13,9 +14,21 @@ export async function getCurrentIdentity( const { userId: clerkUserId } = await auth(); const anonId = request.cookies.get("anon_id")?.value || null; + let username: string | null = null; + if (clerkUserId) { + const user = await currentUser(); + if (user) { + username = + user.username || + [user.firstName, user.lastName].filter(Boolean).join(" ") || + null; + } + } + return { anonId, clerkUserId: clerkUserId || null, isAuthenticated: !!clerkUserId, + username, }; } diff --git a/lib/scoring.ts b/lib/scoring.ts new file mode 100644 index 0000000..900ad6a --- /dev/null +++ b/lib/scoring.ts @@ -0,0 +1,78 @@ +import { embed } from "ai"; + +const EMBEDDING_MODEL = "openai/text-embedding-3-small" as const; + +export async function getEmbedding(text: string): Promise { + const { embedding } = await embed({ + model: EMBEDDING_MODEL, + value: text.toLowerCase().trim(), + }); + return embedding; +} + +export async function getEmbeddings(texts: string[]): Promise { + const results = await Promise.all(texts.map((text) => getEmbedding(text))); + return results; +} + +export function cosineSimilarity(a: number[], b: number[]): number { + if (a.length !== b.length) { + throw new Error("Vectors must have the same length"); + } + + let dotProduct = 0; + let normA = 0; + let normB = 0; + + for (let i = 0; i < a.length; i++) { + dotProduct += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + + const magnitude = Math.sqrt(normA) * Math.sqrt(normB); + if (magnitude === 0) return 0; + + return dotProduct / magnitude; +} + +export async function calculateSemanticScore( + guess: string, + answer: string, +): Promise { + const [guessEmbedding, answerEmbedding] = await getEmbeddings([ + guess, + answer, + ]); + const similarity = cosineSimilarity(guessEmbedding, answerEmbedding); + return Math.max(0, Math.min(1, similarity)); +} + +export function calculateTimeBonus( + timeMs: number, + maxTimeMs: number = 60000, +): number { + const timeSeconds = timeMs / 1000; + if (timeSeconds < 10) return 20; + if (timeSeconds < 30) return 10; + if (timeSeconds < maxTimeMs / 1000) return 5; + return 0; +} + +export function calculateFinalScore( + semanticScore: number, + timeMs: number, + isHumanGuesser: boolean = false, +): { + baseScore: number; + timeBonus: number; + multiplier: number; + finalScore: number; +} { + const baseScore = Math.round(semanticScore * 100); + const timeBonus = calculateTimeBonus(timeMs); + const multiplier = isHumanGuesser ? 1.5 : 1.0; + const finalScore = Math.round((baseScore + timeBonus) * multiplier); + + return { baseScore, timeBonus, multiplier, finalScore }; +} diff --git a/package.json b/package.json index 56b4d65..e2e0f3f 100644 --- a/package.json +++ b/package.json @@ -1,87 +1,87 @@ { - "name": "pintel", - "version": "0.1.0", - "private": true, - "description": "draw • guess • evaluate — A multimodal AI evaluation game", - "scripts": { - "dev": "next dev --turbopack", - "build": "next build", - "start": "next start", - "lint": "next lint", - "format": "bunx @biomejs/biome check --write .", - "format:check": "bunx @biomejs/biome check .", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio" - }, - "dependencies": { - "@ai-sdk/gateway": "^1.0.0", - "@ai-sdk/react": "^1.0.0", - "@clerk/nextjs": "^6.35.6", - "@clerk/themes": "^2.4.41", - "@hookform/resolvers": "^5.2.2", - "@neondatabase/serverless": "^1.0.2", - "@radix-ui/react-accordion": "^1.2.12", - "@radix-ui/react-alert-dialog": "^1.1.15", - "@radix-ui/react-aspect-ratio": "^1.1.8", - "@radix-ui/react-avatar": "^1.1.11", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-context-menu": "^2.2.16", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-hover-card": "^1.1.15", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-menubar": "^1.1.16", - "@radix-ui/react-navigation-menu": "^1.2.14", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-progress": "^1.1.8", - "@radix-ui/react-radio-group": "^1.3.8", - "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-toggle": "^1.1.10", - "@radix-ui/react-toggle-group": "^1.1.11", - "@radix-ui/react-tooltip": "^1.2.8", - "@tanstack/react-query": "^5.90.12", - "ai": "^5.0.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "date-fns": "^4.1.0", - "drizzle-orm": "^0.45.0", - "embla-carousel-react": "^8.6.0", - "input-otp": "^1.4.2", - "lucide-react": "^0.555.0", - "motion": "^12.23.25", - "next": "15.5.8", - "next-themes": "^0.4.6", - "react": "^19.0.0", - "react-day-picker": "^9.11.3", - "react-dom": "^19.0.0", - "react-hook-form": "^7.67.0", - "react-resizable-panels": "^3.0.6", - "recharts": "2.15.4", - "sonner": "^2.0.7", - "svix": "^1.82.0", - "tailwind-merge": "^3.4.0", - "vaul": "^1.1.2", - "zod": "^4.1.13" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4.0.0", - "@tailwindcss/typography": "^0.5.19", - "@types/node": "^22.10.2", - "@types/react": "^19.0.2", - "@types/react-dom": "^19.0.2", - "drizzle-kit": "^0.31.8", - "tailwindcss": "^4.0.0", - "tw-animate-css": "^1.4.0", - "typescript": "^5.7.2" - } + "name": "pintel", + "version": "0.1.0", + "private": true, + "description": "draw • guess • evaluate — A multimodal AI evaluation game", + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint", + "format": "bunx @biomejs/biome check --write .", + "format:check": "bunx @biomejs/biome check .", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@ai-sdk/gateway": "^1.0.0", + "@ai-sdk/react": "^1.0.0", + "@clerk/nextjs": "^6.35.6", + "@clerk/themes": "^2.4.41", + "@hookform/resolvers": "^5.2.2", + "@neondatabase/serverless": "^1.0.2", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-aspect-ratio": "^1.1.8", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-menubar": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.90.12", + "ai": "^5.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "drizzle-orm": "^0.45.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.555.0", + "motion": "^12.23.25", + "next": "15.5.8", + "next-themes": "^0.4.6", + "react": "^19.0.0", + "react-day-picker": "^9.11.3", + "react-dom": "^19.0.0", + "react-hook-form": "^7.67.0", + "react-resizable-panels": "^3.0.6", + "recharts": "2.15.4", + "sonner": "^2.0.7", + "svix": "^1.82.0", + "tailwind-merge": "^3.4.0", + "vaul": "^1.1.2", + "zod": "^4.1.13" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.0", + "@tailwindcss/typography": "^0.5.19", + "@types/node": "^22.10.2", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "drizzle-kit": "^0.31.8", + "tailwindcss": "^4.0.0", + "tw-animate-css": "^1.4.0", + "typescript": "^5.7.2" + } }