diff --git a/.gitignore b/.gitignore index 5ef6a52..62fcee8 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/lib/generated/prisma diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..0481c87 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,316 @@ +# Compile-n-Conquer — Evaluation Pipeline Architecture + +## Overview + +A competitive coding platform where users compete in rounds by solving coding questions. Submissions are evaluated by an LLM pipeline (via Backboard.io) across multiple metrics, scored with configurable weights, and ranked with tiebreaker logic. + +--- + +## Core Concepts + +| Term | Definition | +|---|---| +| **Round** | A competitive session containing N questions | +| **Submission** | A single user's code answer to one question | +| **Evaluation Thread** | A dedicated Backboard.io LLM thread for one scoring metric | +| **Memory** | Persistent Backboard.io memory tracking user skill level over time | + +--- + +## Input Format + +All submissions for a round are batched into a structured payload: + +```json +{ + "roundId": "round_1", + "questions": [ + { "id": "q1", "title": "Two Sum", "description": "...", "difficulty": "medium" }, + { "id": "q2", "title": "...", "description": "...", "difficulty": "..." }, + { "id": "q3", "title": "...", "description": "...", "difficulty": "..." } + ], + "submissions": [ + { + "userId": "user_1", + "responses": [ + { "questionId": "q1", "code": "...", "language": "python", "submittedAt": "ISO-timestamp" }, + { "questionId": "q2", "code": "...", "language": "cpp", "submittedAt": "ISO-timestamp" }, + { "questionId": "q3", "code": "...", "language": "javascript", "submittedAt": "ISO-timestamp" } + ] + }, + { + "userId": "user_2", + "responses": [/* ... */] + }, + { + "userId": "user_3", + "responses": [/* ... */] + } + ] +} +``` + +--- + +## Evaluation Pipeline (4 LLM Threads via Backboard.io) + +### Thread 1 — Time Complexity Analysis + +**Purpose:** Determine the Big-O time complexity for every submission. + +**Backboard Tool Definition:** +``` +name: "analyze_time_complexity" +description: "Analyze and return the Big-O time complexity of the submitted code relative to the problem it solves. Return the complexity class and a numeric efficiency score (1-100) where 100 is optimal." +parameters: + - code (string, required): The submitted source code + - language (string, required): Programming language + - questionContext (string, required): The problem statement for context +output: + - complexity (string): e.g. "O(n)", "O(n log n)", "O(n²)" + - score (number 1-100): Efficiency rating + - reasoning (string): Brief justification +``` + +**Output Format:** +```json +{ + "roundId": "round_1", + "metric": "time_complexity", + "results": { + "user_1": [ + { "questionId": "q1", "complexity": "O(n)", "score": 92, "reasoning": "..." }, + { "questionId": "q2", "complexity": "O(n log n)", "score": 85, "reasoning": "..." }, + { "questionId": "q3", "complexity": "O(n²)", "score": 40, "reasoning": "..." } + ], + "user_2": [/* ... */], + "user_3": [/* ... */] + } +} +``` + +--- + +### Thread 2 — Space Complexity Analysis + +**Purpose:** Determine the Big-O space complexity for every submission. + +**Backboard Tool Definition:** +``` +name: "analyze_space_complexity" +description: "Analyze and return the Big-O space complexity of the submitted code. Consider auxiliary space used (not input space). Return the complexity class and a numeric efficiency score (1-100)." +parameters: + - code (string, required): The submitted source code + - language (string, required): Programming language + - questionContext (string, required): The problem statement for context +output: + - complexity (string): e.g. "O(1)", "O(n)", "O(n²)" + - score (number 1-100): Efficiency rating + - reasoning (string): Brief justification +``` + +**Output Format:** Same structure as Thread 1, with `"metric": "space_complexity"`. + +--- + +### Thread 3 — Originality & Context Relevance Analysis + +**Purpose:** Detect likely GPT-generated / copy-pasted code and evaluate how relevant the solution is to the actual question. + +**Scoring Criteria:** +- **Comment density:** Excessive/formulaic comments → likely AI-generated (penalize) +- **Code style consistency:** Unusual variable naming patterns, overly verbose docstrings +- **Relevance:** Does the code actually solve the given problem, or is it tangential? +- **Pattern detection:** Boilerplate patterns common in LLM outputs + +**Backboard Tool Definition:** +``` +name: "analyze_originality" +description: "Analyze code for originality signals. Check comment density (high = likely AI-generated), code style patterns, and relevance to the problem. Return an originality score (1-10) and a relevance score (1-10)." +parameters: + - code (string, required): The submitted source code + - language (string, required): Programming language + - questionContext (string, required): The problem statement +output: + - originalityScore (number 1-10): 10 = clearly human-written, 1 = clearly AI-generated + - relevanceScore (number 1-10): 10 = perfectly solves the problem, 1 = completely unrelated + - commentDensity (number): Comments-to-code ratio percentage + - flags (string[]): e.g. ["excessive_comments", "boilerplate_docstrings", "generic_variable_names"] + - reasoning (string): Brief justification +``` + +**Output Format:** +```json +{ + "roundId": "round_1", + "metric": "originality", + "results": { + "user_1": [ + { + "questionId": "q1", + "originalityScore": 8, + "relevanceScore": 9, + "commentDensity": 12.5, + "flags": [], + "reasoning": "..." + } + ] + } +} +``` + +--- + +### Thread 4 — Final Ranking (Aggregation Thread) + +**Purpose:** Consume the outputs of Threads 1-3, apply weights, integrate test-case pass rates from DB, and produce final rankings. + +**Backboard Tool Definition:** +``` +name: "compute_final_ranking" +description: "Given all metric scores for all users in a round, compute weighted final scores and produce a ranking. Apply the provided weights. In case of a tie, use the tiebreaker metric (time taken)." +parameters: + - timeComplexityResults (object, required): Output from Thread 1 + - spaceComplexityResults (object, required): Output from Thread 2 + - originalityResults (object, required): Output from Thread 3 + - testCaseResults (object, required): { userId: { passed: N, total: M } } from DB + - weights (object, required): { timeComplexity, spaceComplexity, originality, testCases } + - tiebreakerData (object, required): { userId: timeTakenMs } for each user +output: + - rankings (array): Ordered list of { userId, rank, finalScore, breakdown } +``` + +--- + +## Scoring Formula + +``` +finalScore = (TC_score × W_tc) + (SC_score × W_sc) + (originality_score × W_orig) + (testCase_score × W_test) +``` + +**Default Weights:** +| Metric | Weight | Max Raw Score | Normalized To | +|---|---|---|---| +| Time Complexity | 25% | 100 per question | 0-25 | +| Space Complexity | 20% | 100 per question | 0-20 | +| Originality + Relevance | 15% | 10 per question | 0-15 | +| Test Cases Passed | 40% | 100% pass rate | 0-40 | + +**Total Max Score:** 100 per question, averaged across all questions in the round. + +**Tiebreaker:** If two users have the same final score, the user who submitted in less total time ranks higher. + +--- + +## Memory System (`/memory`) + +Tracks persistent user profiles across rounds for adaptive difficulty and skill classification. + +**Skill Tiers:** +| Tier | Criteria | +|---|---| +| Beginner | Avg score < 30, struggles with basic DS | +| Intermediate | Avg score 30-60, consistent O(n²) or better | +| Advanced | Avg score 60-85, uses optimal approaches often | +| Pro | Avg score 85+, consistently optimal TC/SC | + +**Memory stores:** +- Historical round scores and progression trend +- Common error patterns (off-by-one, TLE, MLE) +- Preferred algorithms and languages +- Skill tier + promotion/demotion history +- Personalized coaching recommendations + +**Promotion Logic:** +- 3 consecutive rounds with avg score in next tier → promote +- 2 consecutive rounds below current tier threshold → demote +- Memory is queried at round start to set adaptive difficulty + +--- + +## API Endpoints + +### `POST /api/assistant` — Evaluation Pipeline + +**Request:** +```json +{ + "roundId": "round_1", + "action": "evaluate", + "submissions": [/* full round submissions payload */], + "weights": { "timeComplexity": 0.25, "spaceComplexity": 0.20, "originality": 0.15, "testCases": 0.40 }, + "testCaseResults": { "user_1": { "passed": 8, "total": 10 }, ... }, + "tiebreakerData": { "user_1": 145000, ... } +} +``` + +**Response:** +```json +{ + "success": true, + "roundId": "round_1", + "evaluationId": "eval_xxx", + "rankings": [ + { + "rank": 1, + "userId": "user_2", + "finalScore": 87.5, + "breakdown": { + "timeComplexity": { "avgScore": 90, "weighted": 22.5 }, + "spaceComplexity": { "avgScore": 85, "weighted": 17.0 }, + "originality": { "avgScore": 8.5, "weighted": 12.75 }, + "testCases": { "passRate": 0.9, "weighted": 36.0 } + }, + "timeTakenMs": 120000 + } + ], + "threadIds": { + "timeComplexity": "thread_tc_xxx", + "spaceComplexity": "thread_sc_xxx", + "originality": "thread_orig_xxx", + "ranking": "thread_rank_xxx" + } +} +``` + +### `POST /api/memory` — Player Profile Memory + +**Actions:** `initialize`, `update`, `retrieve`, `classify` + +--- + +## Execution Flow Diagram + +``` +Round Ends → Collect All Submissions + │ + ├──→ Thread 1: Time Complexity Analysis ──→ Store TC Results + ├──→ Thread 2: Space Complexity Analysis ──→ Store SC Results + ├──→ Thread 3: Originality Analysis ────────→ Store Orig Results + │ (Threads 1-3 run in parallel) + │ + ▼ + Thread 4: Aggregation & Ranking + │ + ├── Fetch test case pass rates from DB + ├── Apply scoring weights + ├── Handle tiebreakers (time taken) + │ + ▼ + Final Rankings Published + │ + ▼ + /memory: Update user profiles & skill tiers +``` + +--- + +## Mock Testing Strategy + +All evaluation logic is testable without live Backboard API calls: + +1. **Unit tests for scoring math** — Weight application, normalization, tiebreaker logic +2. **Mock LLM responses** — Predefined tool call outputs for each thread +3. **End-to-end pipeline test** — Full round with 3 mock users, 3 questions, verify ranking order +4. **Edge cases** — Tied scores, empty submissions, single user, all-zero scores +5. **Memory classification test** — Verify tier promotion/demotion with historical mock data diff --git a/app/api/assistant/route.ts b/app/api/assistant/route.ts new file mode 100644 index 0000000..516e204 --- /dev/null +++ b/app/api/assistant/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { BackboardClient } from 'backboard-sdk'; + +const bb = new BackboardClient({ + apiKey: process.env.BACKBOARD_API_KEY!, +}); + +// Type for non-streamed message responses +type BBMessageResponse = { + content: string; + status: string; + toolCalls: Array<{ id: string; function: { name: string; parsed_arguments: any } }> | null; + runId?: string; + threadId: string; + memoryOperationId?: string; +}; + +// Mock/Placeholder for database operations +const mockMatchData = { + getMatch: (matchId: string) => ({ + id: matchId, + threadId: null as string | null // Replace with real DB lookup + }), + updateMatchThread: (matchId: string, threadId: string) => { + console.log(`Mock DB: Saved thread ${threadId} for match ${matchId}`); + } +}; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { matchId, code, language, userId } = body; + + if (!matchId || !code || !language || !userId) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + // 1. Get or Create Assistant (Logic simplified) + // In production, store ASSISTANT_ID in an env var to avoid re-creating + const assistant = await bb.createAssistant({ + name: "Compile-n-Conquer GM", + system_prompt: "You are a competitive coding Game Master. Analyze complexity and efficiency.", + tools: [{ + type: "function", + function: { + name: "analyze_code_complexity", + description: "Analyze Big O complexity", + parameters: { + type: "object", + properties: { + complexity: { type: "string" }, + score: { type: "number" } + } + } + } + }] + }); + + // 2. Thread Management + const match = mockMatchData.getMatch(matchId); + let threadId: string = match.threadId ?? ''; + + if (!threadId) { + const thread = await bb.createThread(assistant.assistantId); + threadId = thread.threadId; + mockMatchData.updateMatchThread(matchId, threadId); + } + + // 3. Send Message & Handle Interaction + let response = await bb.addMessage(threadId, { + content: `User ${userId} submitted ${language} code:\n\`\`\`\n${code}\n\`\`\``, + llm_provider: "openai", + model_name: "gpt-4o", + stream: false + }) as BBMessageResponse; + + // 4. Handle Tool Calls (If LLM triggers the analyzer) + if (response.status === "REQUIRES_ACTION" && response.toolCalls) { + const toolOutputs = response.toolCalls.map((tc) => ({ + tool_call_id: tc.id, + output: JSON.stringify({ complexity: "O(n)", score: 85 }) // Mock analysis logic + })); + + const toolResponse = await bb.submitToolOutputs( + threadId, + response.runId!, + toolOutputs, + false + ) as BBMessageResponse; + response = toolResponse; + } + + return NextResponse.json({ + success: true, + evaluation: response.content, + threadId + }); + + } catch (error: any) { + console.error('API Error:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..684c9d0 --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,140 @@ +// ============================================================ +// POST /api/auth/signout — Clear auth cookie +// GET /api/auth/me — Get current user from JWT +// ============================================================ + +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getUserFromRequest } from "@/lib/auth"; +import { getSocketServer } from "@/lib/socket"; +import { listAvailableMatches } from "@/lib/lobby"; + +export async function GET(req: NextRequest) { + const payload = getUserFromRequest(req); + + if (!payload) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + try { + const user = await prisma.user.findUnique({ + where: { id: payload.userId }, + select: { + id: true, + username: true, + email: true, + displayName: true, + title: true, + skillTier: true, + elo: true, + totalWins: true, + totalMatches: true, + totalXP: true, + streak: true, + createdAt: true, + }, + }); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json({ user }); + } catch (error) { + console.error("Me error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + const payload = getUserFromRequest(req); + + if (payload) { + try { + const userId = payload.userId; + const entries = await prisma.matchPlayer.findMany({ + where: { userId }, + select: { matchId: true }, + }); + + const matchIds = Array.from( + new Set(entries.map((entry) => entry.matchId)), + ); + + if (matchIds.length > 0) { + await prisma.matchPlayer.deleteMany({ where: { userId } }); + + const updatedMatches: any[] = []; + const deletedMatchIds: string[] = []; + + for (const matchId of matchIds) { + const match = await prisma.match.findUnique({ + where: { id: matchId }, + include: { + players: { + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + elo: true, + }, + }, + }, + }, + }, + }); + + if (!match) continue; + + const playerCount = match.players.length; + + if (playerCount === 0) { + if (match.status === "WAITING") { + await prisma.match.delete({ where: { id: matchId } }); + deletedMatchIds.push(matchId); + continue; + } + + await prisma.match.update({ + where: { id: matchId }, + data: { status: "CANCELLED", endedAt: new Date() }, + }); + continue; + } + + updatedMatches.push(match); + } + + const io = getSocketServer(); + if (io) { + const matches = await listAvailableMatches(); + io.emit("lobby:list", matches); + updatedMatches.forEach((match) => { + io.emit("lobby:update", match); + }); + deletedMatchIds.forEach((matchId) => { + io.emit("lobby:deleted", { matchId }); + }); + } + } + } catch (error) { + console.error("Signout cleanup error:", error); + } + } + + // Sign out: clear the cookie + const response = NextResponse.json({ success: true }); + response.cookies.set("token", "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 0, + path: "/", + }); + return response; +} diff --git a/app/api/auth/signin/route.ts b/app/api/auth/signin/route.ts new file mode 100644 index 0000000..3eb68cb --- /dev/null +++ b/app/api/auth/signin/route.ts @@ -0,0 +1,86 @@ +// ============================================================ +// POST /api/auth/signin — Login with username/email + password +// ============================================================ + +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { verifyPassword, signToken } from "@/lib/auth"; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { login, password } = body; + + // login can be username or email + if (!login || !password) { + return NextResponse.json( + { error: "login (username or email) and password are required" }, + { status: 400 } + ); + } + + // Find user by email or username + const user = await prisma.user.findFirst({ + where: { + OR: [{ email: login }, { username: login }], + }, + }); + + if (!user) { + return NextResponse.json( + { error: "Invalid credentials" }, + { status: 401 } + ); + } + + // Verify password + const valid = await verifyPassword(password, user.passwordHash); + if (!valid) { + return NextResponse.json( + { error: "Invalid credentials" }, + { status: 401 } + ); + } + + // Sign JWT + const token = signToken({ + userId: user.id, + username: user.username, + email: user.email, + }); + + const response = NextResponse.json({ + user: { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + title: user.title, + skillTier: user.skillTier, + elo: user.elo, + totalWins: user.totalWins, + totalMatches: user.totalMatches, + totalXP: user.totalXP, + streak: user.streak, + }, + token, + }); + + // Also set as httpOnly cookie + response.cookies.set("token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, + path: "/", + }); + + return response; + } catch (error) { + console.error("Signin error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts new file mode 100644 index 0000000..d926422 --- /dev/null +++ b/app/api/auth/signup/route.ts @@ -0,0 +1,97 @@ +// ============================================================ +// POST /api/auth/signup — Register a new user +// ============================================================ + +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { hashPassword, signToken } from "@/lib/auth"; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { username, email, password, displayName } = body; + + // Validate required fields + if (!username || !email || !password) { + return NextResponse.json( + { error: "username, email, and password are required" }, + { status: 400 } + ); + } + + if (password.length < 6) { + return NextResponse.json( + { error: "Password must be at least 6 characters" }, + { status: 400 } + ); + } + + // Check if user already exists + const existing = await prisma.user.findFirst({ + where: { + OR: [{ email }, { username }], + }, + }); + + if (existing) { + const field = existing.email === email ? "email" : "username"; + return NextResponse.json( + { error: `A user with that ${field} already exists` }, + { status: 409 } + ); + } + + // Create user + const passwordHash = await hashPassword(password); + const user = await prisma.user.create({ + data: { + username, + email, + passwordHash, + displayName: displayName || username, + }, + }); + + // Sign JWT + const token = signToken({ + userId: user.id, + username: user.username, + email: user.email, + }); + + const response = NextResponse.json( + { + user: { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + title: user.title, + skillTier: user.skillTier, + elo: user.elo, + totalXP: user.totalXP, + streak: user.streak, + }, + token, + }, + { status: 201 } + ); + + // Also set as httpOnly cookie + response.cookies.set("token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 7 days + path: "/", + }); + + return response; + } catch (error) { + console.error("Signup error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/execute/route.ts b/app/api/execute/route.ts new file mode 100644 index 0000000..9ffeddd --- /dev/null +++ b/app/api/execute/route.ts @@ -0,0 +1,55 @@ +// ============================================================ +// POST /api/execute — Run code via Piston (standalone, no match) +// GET /api/execute — Get available runtimes +// ============================================================ +// Useful for testing code before submitting in a match. + +import { NextRequest, NextResponse } from "next/server"; +import { getUserFromRequest } from "@/lib/auth"; +import { runCode, getRuntimes } from "@/lib/piston"; + +export async function POST(req: NextRequest) { + const payload = getUserFromRequest(req); + if (!payload) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + try { + const body = await req.json(); + const { language, code, stdin = "" } = body; + + if (!language || !code) { + return NextResponse.json( + { error: "language and code are required" }, + { status: 400 } + ); + } + + const result = await runCode(language, code, stdin); + + return NextResponse.json({ + stdout: result.run.stdout, + stderr: result.run.stderr, + exitCode: result.run.code, + output: result.run.output, + }); + } catch (error: any) { + console.error("Execute error:", error); + return NextResponse.json( + { error: error.message || "Execution failed" }, + { status: 500 } + ); + } +} + +export async function GET() { + try { + const runtimes = await getRuntimes(); + return NextResponse.json({ runtimes }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || "Failed to fetch runtimes" }, + { status: 500 } + ); + } +} diff --git a/app/api/match/[matchId]/evaluate/route.ts b/app/api/match/[matchId]/evaluate/route.ts new file mode 100644 index 0000000..f4b3ef7 --- /dev/null +++ b/app/api/match/[matchId]/evaluate/route.ts @@ -0,0 +1,202 @@ +// ============================================================ +// POST /api/match/[matchId]/evaluate — Trigger LLM evaluation +// ============================================================ +// Called when all players have submitted (or time is up). +// Runs the Backboard evaluation pipeline and updates rankings. + +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getUserFromRequest } from "@/lib/auth"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ matchId: string }> } +) { + const payload = getUserFromRequest(req); + if (!payload) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const { matchId } = await params; + + try { + // Get match with all data needed for evaluation + const match = await prisma.match.findUnique({ + where: { id: matchId }, + include: { + players: { include: { user: true } }, + questions: { include: { question: true } }, + submissions: true, + }, + }); + + if (!match) { + return NextResponse.json({ error: "Match not found" }, { status: 404 }); + } + + if (match.status !== "IN_PROGRESS") { + return NextResponse.json( + { error: `Match is ${match.status}, cannot evaluate` }, + { status: 400 } + ); + } + + // Transition to EVALUATING + await prisma.match.update({ + where: { id: matchId }, + data: { status: "EVALUATING" }, + }); + + // Build the RoundSubmissions payload for Backboard + const roundSubmissions = { + roundId: matchId, + questions: match.questions.map((mq) => ({ + id: mq.question.title, + title: mq.question.title, + description: mq.question.description, + difficulty: mq.question.difficulty.toLowerCase() as "easy" | "medium" | "hard", + })), + submissions: match.players.map((player) => ({ + userId: player.user.displayName || player.user.username, + responses: match.submissions + .filter( + (s) => + s.userId === (player.user.displayName || player.user.username), + ) + .map((s) => ({ + questionId: s.questionId, + code: s.code, + language: s.language, + submittedAt: s.submittedAt.toISOString(), + })), + })), + }; + + // For now, compute rankings based on test case results + // The full Backboard LLM pipeline can be triggered async + const playerScores = match.players.map((player) => { + const playerName = player.user.displayName || player.user.username; + const playerSubmissions = match.submissions.filter( + (s) => s.userId === playerName + ); + + // Calculate average test pass rate + const totalPassed = playerSubmissions.reduce( + (sum, s) => sum + s.testsPassed, + 0 + ); + const totalTests = playerSubmissions.reduce( + (sum, s) => sum + s.testsTotal, + 0 + ); + const passRate = totalTests > 0 ? totalPassed / totalTests : 0; + + // For SHORTEST mode, factor in code length + const avgCharCount = + playerSubmissions.length > 0 + ? playerSubmissions.reduce((sum, s) => sum + (s.charCount || 0), 0) / + playerSubmissions.length + : Infinity; + + // For FASTEST mode, factor in submission time + const avgTime = + playerSubmissions.length > 0 + ? playerSubmissions.reduce( + (sum, s) => sum + s.submittedAt.getTime(), + 0 + ) / playerSubmissions.length + : Infinity; + + let score = passRate * 100; + + // Mode-specific scoring adjustments + if (match.mode === "SHORTEST") { + // Lower char count = higher bonus (max 20 points) + const charBonus = Math.max(0, 20 - avgCharCount / 50); + score += charBonus; + } + + return { + playerId: player.id, + userId: player.userId, + score, + avgTime, + }; + }); + + // Sort by score (desc), then by time (asc) for tiebreaker + playerScores.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.avgTime - b.avgTime; + }); + + // Update rankings in a transaction + await prisma.$transaction(async (tx) => { + for (let i = 0; i < playerScores.length; i++) { + await tx.matchPlayer.update({ + where: { id: playerScores[i].playerId }, + data: { + rank: i + 1, + score: playerScores[i].score, + }, + }); + + // Update user stats + const isWinner = i === 0; + await tx.user.update({ + where: { id: playerScores[i].userId }, + data: { + totalMatches: { increment: 1 }, + ...(isWinner ? { totalWins: { increment: 1 } } : {}), + }, + }); + } + + // Mark match as completed + await tx.match.update({ + where: { id: matchId }, + data: { + status: "COMPLETED", + endedAt: new Date(), + }, + }); + }); + + // Return final rankings + const finalMatch = await prisma.match.findUnique({ + where: { id: matchId }, + include: { + players: { + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + }, + }, + }, + orderBy: { rank: "asc" }, + }, + }, + }); + + return NextResponse.json({ + message: "Evaluation complete", + rankings: finalMatch?.players.map((p) => ({ + rank: p.rank, + userId: p.userId, + username: p.user.username, + displayName: p.user.displayName, + score: p.score, + })), + roundSubmissions, // For debugging / Backboard integration + }); + } catch (error) { + console.error("Evaluate error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/match/[matchId]/route.ts b/app/api/match/[matchId]/route.ts new file mode 100644 index 0000000..4581632 --- /dev/null +++ b/app/api/match/[matchId]/route.ts @@ -0,0 +1,158 @@ +// ============================================================ +// GET /api/match/[matchId] — Get match details +// POST /api/match/[matchId] — Start the match (transition to IN_PROGRESS) +// ============================================================ + +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getUserFromRequest } from "@/lib/auth"; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ matchId: string }> } +) { + const payload = getUserFromRequest(req); + if (!payload) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const { matchId } = await params; + + try { + const viewer = await prisma.user.findUnique({ + where: { id: payload.userId }, + select: { username: true, displayName: true }, + }); + const viewerName = viewer?.displayName || viewer?.username || payload.username; + + const match = await prisma.match.findUnique({ + where: { id: matchId }, + include: { + players: { + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + skillTier: true, + elo: true, + }, + }, + }, + }, + questions: { + include: { question: true }, + orderBy: { order: "asc" }, + }, + submissions: { + where: { userId: viewerName }, + select: { + id: true, + questionId: true, + language: true, + submittedAt: true, + testsPassed: true, + testsTotal: true, + }, + }, + }, + }); + + if (!match) { + return NextResponse.json({ error: "Match not found" }, { status: 404 }); + } + + // Verify user is a player in this match + const isPlayer = match.players.some( + (p) => + p.userId === payload.userId || + p.user.username === payload.username || + p.user.displayName === payload.username, + ); + if (!isPlayer) { + return NextResponse.json( + { error: "You are not a player in this match" }, + { status: 403 } + ); + } + + const normalizedQuestions = match.questions.map((entry) => { + const question = entry.question + const testCases = Array.isArray(question.testCases) + ? question.testCases + : [] + const hiddenTestCases = Array.isArray(question.hiddenTestCases) + ? question.hiddenTestCases + : [] + return { + ...entry, + question: { + ...question, + testCases, + hiddenTestCases, + }, + } + }) + + return NextResponse.json({ + match: { + ...match, + questions: normalizedQuestions, + }, + }); + } catch (error) { + console.error("Match fetch error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// Start match — any player can trigger this, transitions WAITING → IN_PROGRESS +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ matchId: string }> } +) { + const payload = getUserFromRequest(req); + if (!payload) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const { matchId } = await params; + + try { + const match = await prisma.match.findUnique({ + where: { id: matchId }, + include: { players: true }, + }); + + if (!match) { + return NextResponse.json({ error: "Match not found" }, { status: 404 }); + } + + if (match.status !== "WAITING") { + return NextResponse.json( + { error: `Match is already ${match.status}` }, + { status: 400 } + ); + } + + const updated = await prisma.match.update({ + where: { id: matchId }, + data: { + status: "IN_PROGRESS", + startedAt: new Date(), + }, + }); + + return NextResponse.json({ match: updated }); + } catch (error) { + console.error("Match start error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/match/[matchId]/submit/route.ts b/app/api/match/[matchId]/submit/route.ts new file mode 100644 index 0000000..2db9721 --- /dev/null +++ b/app/api/match/[matchId]/submit/route.ts @@ -0,0 +1,164 @@ +// ============================================================ +// POST /api/match/[matchId]/submit — Submit code for a question +// ============================================================ +// Stores submission without running code. + +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getUserFromRequest } from "@/lib/auth"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ matchId: string }> } +) { + const payload = getUserFromRequest(req); + if (!payload) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const { matchId } = await params; + + try { + const body = await req.json(); + const { questionId, code, language } = body; + + if (!questionId || !code || !language) { + return NextResponse.json( + { error: "questionId, code, and language are required" }, + { status: 400 } + ); + } + + // Verify match exists and is in progress + const match = await prisma.match.findUnique({ + where: { id: matchId }, + include: { + players: { + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + }, + }, + }, + }, + questions: { include: { question: true } }, + }, + }); + + if (!match) { + return NextResponse.json({ error: "Match not found" }, { status: 404 }); + } + + if (match.status !== "IN_PROGRESS") { + return NextResponse.json( + { error: `Match is ${match.status}, not accepting submissions` }, + { status: 400 } + ); + } + + // Verify user is a player + const isPlayer = match.players.some( + (p) => + p.userId === payload.userId || + p.user.username === payload.username || + p.user.displayName === payload.username, + ); + if (!isPlayer) { + return NextResponse.json( + { error: "You are not a player in this match" }, + { status: 403 } + ); + } + + // Find the question + const matchQuestion = match.questions.find( + (mq) => mq.questionId === questionId + ); + if (!matchQuestion) { + return NextResponse.json( + { error: "Question not part of this match" }, + { status: 400 } + ); + } + + const user = await prisma.user.findUnique({ + where: { id: payload.userId }, + select: { username: true, displayName: true }, + }); + const userName = user?.displayName || user?.username || payload.username; + + // Save submission (no execution required) + const submission = await prisma.submission.create({ + data: { + matchId, + userId: userName, + questionId: matchQuestion.question.title, + code, + language, + charCount: code.length, + }, + }); + + const questionTitles = match.questions.map((mq) => mq.question.title); + const questionTitleSet = new Set(questionTitles); + const playerNames = match.players.map( + (player) => player.user.displayName || player.user.username, + ); + + if (playerNames.length > 0 && questionTitles.length > 0) { + const submissions = await prisma.submission.findMany({ + where: { + matchId, + userId: { in: playerNames }, + }, + select: { + userId: true, + questionId: true, + }, + }); + + const progress = new Map>(); + for (const entry of submissions) { + if (!questionTitleSet.has(entry.questionId)) continue; + if (!progress.has(entry.userId)) { + progress.set(entry.userId, new Set()); + } + progress.get(entry.userId)?.add(entry.questionId); + } + + const allCompleted = playerNames.every( + (name) => (progress.get(name)?.size ?? 0) >= questionTitles.length, + ); + + if (allCompleted) { + await prisma.match.update({ + where: { id: matchId }, + data: { + status: "COMPLETED", + endedAt: new Date(), + }, + }); + } + } + + return NextResponse.json({ + submission: { + id: submission.id, + testsPassed: submission.testsPassed, + testsTotal: submission.testsTotal, + stdout: submission.stdout, + stderr: submission.stderr, + exitCode: submission.exitCode, + }, + }); + } catch (error) { + console.error("Submit error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/match/available/route.ts b/app/api/match/available/route.ts new file mode 100644 index 0000000..887f396 --- /dev/null +++ b/app/api/match/available/route.ts @@ -0,0 +1,25 @@ +// ============================================================ +// GET /api/match/available — List waiting matches (lobbies) +// ============================================================ + +import { NextRequest, NextResponse } from "next/server" +import { getUserFromRequest } from "@/lib/auth" +import { listAvailableMatches } from "@/lib/lobby" + +export async function GET(req: NextRequest) { + const payload = getUserFromRequest(req) + if (!payload) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + try { + const matches = await listAvailableMatches() + return NextResponse.json({ matches }) + } catch (error) { + console.error("Match list error:", error) + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ) + } +} diff --git a/app/api/match/create/route.ts b/app/api/match/create/route.ts new file mode 100644 index 0000000..9bbf268 --- /dev/null +++ b/app/api/match/create/route.ts @@ -0,0 +1,93 @@ +// ============================================================ +// POST /api/match/create — Create a new match (lobby) +// ============================================================ + +import { NextRequest, NextResponse } from "next/server" +import { prisma } from "@/lib/db" +import { getUserFromRequest } from "@/lib/auth" +import { listAvailableMatches } from "@/lib/lobby" +import { getSocketServer } from "@/lib/socket" + +export async function POST(req: NextRequest) { + const payload = getUserFromRequest(req) + if (!payload) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const body = await req.json().catch(() => ({})) + const mode = body?.mode || "FASTEST" + + try { + const result = await prisma.$transaction(async (tx) => { + const match = await tx.match.create({ + data: { + mode, + status: "WAITING", + }, + }) + + await tx.matchPlayer.create({ + data: { + matchId: match.id, + userId: payload.userId, + }, + }) + + const questions = await tx.$queryRaw<{ id: string }[]> + `SELECT id FROM "questions" ORDER BY RANDOM() LIMIT 3` + + if (questions.length < 3) { + throw new Error("Not enough questions to start a match") + } + + await tx.matchQuestion.createMany({ + data: questions.map((q, index) => ({ + matchId: match.id, + questionId: q.id, + order: index, + })), + }) + + return match + }) + + const hydrated = await prisma.match.findUnique({ + where: { id: result.id }, + include: { + players: { + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + elo: true, + }, + }, + }, + }, + }, + }) + + const io = getSocketServer() + if (io) { + const matches = await listAvailableMatches() + io.emit("lobby:list", matches) + io.emit("lobby:update", hydrated) + } + + const safeMatch = hydrated + ? { ...hydrated, maxPlayers: hydrated.maxPlayers ?? 3 } + : hydrated + + return NextResponse.json({ + match: safeMatch, + }) + } catch (error: any) { + console.error("Match create error:", error) + const message = + error instanceof Error ? error.message : "Internal server error" + const status = message.includes("Not enough questions") ? 400 : 500 + return NextResponse.json({ error: message }, { status }) + } +} diff --git a/app/api/match/join/route.ts b/app/api/match/join/route.ts new file mode 100644 index 0000000..11f7f00 --- /dev/null +++ b/app/api/match/join/route.ts @@ -0,0 +1,149 @@ +// ============================================================ +// POST /api/match/join — Join an existing match by code +// ============================================================ + +import { NextRequest, NextResponse } from "next/server" +import { prisma } from "@/lib/db" +import { getUserFromRequest } from "@/lib/auth" +import { listAvailableMatches } from "@/lib/lobby" +import { getSocketServer } from "@/lib/socket" + +export async function POST(req: NextRequest) { + const payload = getUserFromRequest(req) + if (!payload) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const body = await req.json().catch(() => ({})) + const matchId = body?.matchId ? String(body.matchId) : null + + if (!matchId) { + return NextResponse.json( + { error: "matchId is required" }, + { status: 400 }, + ) + } + + try { + const { hydrated, shouldStart } = await prisma.$transaction(async (tx) => { + const match = await tx.match.findUnique({ + where: { id: matchId }, + include: { players: true }, + }) + + if (!match) { + throw new Error("MATCH_NOT_FOUND") + } + + if (match.status !== "WAITING") { + throw new Error(`MATCH_STATUS:${match.status}`) + } + + const alreadyInMatch = match.players.some( + (p) => p.userId === payload.userId, + ) + + const maxPlayers = match.maxPlayers ?? 3 + if (!alreadyInMatch && match.players.length >= maxPlayers) { + throw new Error("MATCH_FULL") + } + + if (!alreadyInMatch) { + await tx.matchPlayer.create({ + data: { + matchId: match.id, + userId: payload.userId, + }, + }) + } + + const totalPlayers = await tx.matchPlayer.count({ + where: { matchId: match.id }, + }) + if (totalPlayers > maxPlayers) { + if (!alreadyInMatch) { + await tx.matchPlayer.deleteMany({ + where: { matchId: match.id, userId: payload.userId }, + }) + } + throw new Error("MATCH_FULL") + } + const shouldStart = totalPlayers >= maxPlayers + + if (shouldStart) { + await tx.match.update({ + where: { id: match.id }, + data: { + status: "IN_PROGRESS", + startedAt: new Date(), + }, + }) + } + + const hydrated = await tx.match.findUnique({ + where: { id: match.id }, + include: { + players: { + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + elo: true, + }, + }, + }, + }, + }, + }) + + return { hydrated, shouldStart } + }) + + if (!hydrated) { + return NextResponse.json({ error: "Match not found" }, { status: 404 }) + } + + const safeMatch = { + ...hydrated, + maxPlayers: hydrated.maxPlayers ?? 3, + } + + const io = getSocketServer() + if (io) { + const matches = await listAvailableMatches() + io.emit("lobby:list", matches) + io.emit("lobby:update", safeMatch) + if (shouldStart) { + io.emit("lobby:started", { matchId: safeMatch.id }) + } + } + + return NextResponse.json({ + match: safeMatch, + started: shouldStart, + }) + } catch (error) { + const message = error instanceof Error ? error.message : "" + if (message === "MATCH_NOT_FOUND") { + return NextResponse.json({ error: "Match not found" }, { status: 404 }) + } + if (message === "MATCH_FULL") { + return NextResponse.json({ error: "Match is full" }, { status: 400 }) + } + if (message.startsWith("MATCH_STATUS:")) { + const status = message.split(":")[1] || "IN_PROGRESS" + return NextResponse.json( + { error: `Match already ${status}` }, + { status: 400 }, + ) + } + + console.error("Match join error:", error) + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ) + } +} diff --git a/app/api/memory/route.ts b/app/api/memory/route.ts new file mode 100644 index 0000000..49c5fab --- /dev/null +++ b/app/api/memory/route.ts @@ -0,0 +1,195 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { BackboardClient } from 'backboard-sdk'; + +// Type for non-streamed message responses +type BBMessageResponse = { + content: string; + status: string; + threadId: string; + memoryOperationId?: string; +}; + +// Mock/Placeholder for user database operations (replace with actual Prisma calls later) +const mockUserData = { + getUserProfile: (userId: string) => ({ + id: userId, + username: `user_${userId}`, + pastPerformance: "Student at VIT, intermediate competitive programmer", + commonErrors: [ + "Off-by-one errors in array indexing", + "Inefficient nested loops leading to O(n²) complexity", + "Missing edge case handling for empty inputs" + ], + preferredAlgorithms: [ + "Dynamic Programming", + "Binary Search", + "Two Pointers technique" + ], + skillLevel: "intermediate", + totalMatches: 15, + winRate: 0.67, + averageComplexity: "O(n log n)", + learningGoals: [ + "Improve graph algorithm understanding", + "Master advanced DP patterns", + "Reduce time complexity in solutions" + ] + }), + updateUserMemorySession: (userId: string, memoryData: any) => { + console.log(`Mock: Updated memory session for user ${userId}`, memoryData); + return { success: true, lastUpdated: new Date().toISOString() }; + } +}; + +export async function POST(req: NextRequest) { + try { + // Parse request body + const body = await req.json(); + const { userId, action, memoryContext } = body; + + // Validate required fields + if (!userId || !action) { + return NextResponse.json( + { error: 'Missing required fields: userId, action' }, + { status: 400 } + ); + } + + // Validate action type + if (!['update', 'initialize', 'retrieve'].includes(action)) { + return NextResponse.json( + { error: 'Invalid action. Must be: update, initialize, or retrieve' }, + { status: 400 } + ); + } + + if (!process.env.BACKBOARD_API_KEY) { + return NextResponse.json( + { error: 'Backboard API key not configured' }, + { status: 500 } + ); + } + + // Initialize Backboard client + const client = new BackboardClient({ + apiKey: process.env.BACKBOARD_API_KEY + }); + + // Create or retrieve assistant for memory management + const assistant = await client.createAssistant({ + name: "Compile-n-Conquer Player Profile Manager", + system_prompt: `You are the Player Profile Manager for Compile-n-Conquer. + You maintain persistent memory about each player's: + - Coding patterns and preferred algorithms + - Common mistakes and learning areas + - Performance history and skill progression + - Personalized coaching recommendations + + Use this information to provide personalized feedback and adaptive difficulty. + Always maintain context about the player's journey and growth areas.` + }); + + // Mock: Get user profile data (replace with actual Prisma call) + const userProfile = mockUserData.getUserProfile(userId); + + let result; + + switch (action) { + case 'initialize': + case 'update': + // Create a thread for memory management + const thread = await client.createThread(assistant.assistantId); + + // Prepare memory context + const contextToStore = memoryContext || { + userProfile, + timestamp: new Date().toISOString(), + sessionType: 'profile_update' + }; + + // Build comprehensive memory message + const memoryMessage = ` +Player Profile Update for User ${userId}: + +**Background:** ${userProfile.pastPerformance} + +**Skill Assessment:** +- Level: ${userProfile.skillLevel} +- Total Matches: ${userProfile.totalMatches} +- Win Rate: ${(userProfile.winRate * 100).toFixed(1)}% +- Average Complexity: ${userProfile.averageComplexity} + +**Common Challenges:** +${userProfile.commonErrors.map(error => `- ${error}`).join('\n')} + +**Strengths & Preferences:** +${userProfile.preferredAlgorithms.map(algo => `- ${algo}`).join('\n')} + +**Learning Goals:** +${userProfile.learningGoals.map(goal => `- ${goal}`).join('\n')} + +Remember this context for personalized coaching and adaptive difficulty in future interactions. +`; + + // Store memory with Backboard's memory feature + const memoryResponse = await client.addMessage(thread.threadId, { + content: memoryMessage, + memory: "Auto", // Enable automatic memory saving + stream: false + }) as BBMessageResponse; + + // Mock: Update local database with memory session info + const updateResult = mockUserData.updateUserMemorySession(userId, { + threadId: thread.threadId, + memoryOperationId: memoryResponse.memoryOperationId, + contextStored: contextToStore + }); + + result = { + action: 'updated', + userId, + threadId: thread.threadId, + memoryOperationId: memoryResponse.memoryOperationId, + userProfile, + updateResult + }; + break; + + case 'retrieve': + // Create a new thread to test memory recall + const retrieveThread = await client.createThread(assistant.assistantId); + + // Query what the assistant remembers about this user + const recallResponse = await client.addMessage(retrieveThread.threadId, { + content: `What do you remember about user ${userId}? Provide a comprehensive summary of their profile, strengths, weaknesses, and learning journey.`, + memory: "Auto", // Search and retrieve saved memories + stream: false + }) as BBMessageResponse; + + result = { + action: 'retrieved', + userId, + threadId: retrieveThread.threadId, + memorizedProfile: recallResponse.content, + currentProfile: userProfile + }; + break; + } + + return NextResponse.json({ + success: true, + ...result, + timestamp: new Date().toISOString() + }); + + } catch (error) { + console.error('Memory API Error:', error); + return NextResponse.json( + { + error: 'Failed to process memory operation', + details: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/piston/[...path]/route.ts b/app/api/piston/[...path]/route.ts new file mode 100644 index 0000000..5bc6554 --- /dev/null +++ b/app/api/piston/[...path]/route.ts @@ -0,0 +1,45 @@ +export const dynamic = "force-dynamic" + +const PISTON_BASE = + process.env.PISTON_BASE ?? + "https://k4l8m636-2000.inc1.devtunnels.ms/api/v2" + +const allowedPaths = new Set(["runtimes", "execute"]) + +const forward = async ( + req: Request, + { params }: { params: { path?: string[] } }, +) => { + const path = params.path?.join("/") ?? "" + if (!allowedPaths.has(path)) { + return new Response("Not found", { status: 404 }) + } + + const url = `${PISTON_BASE}/${path}` + const headers = new Headers(req.headers) + headers.delete("host") + headers.delete("connection") + headers.delete("content-length") + + const init: RequestInit = { + method: req.method, + headers, + cache: "no-store", + } + + if (req.method !== "GET" && req.method !== "HEAD") { + init.body = await req.text() + } + + const response = await fetch(url, init) + const responseHeaders = new Headers(response.headers) + responseHeaders.delete("content-encoding") + + return new Response(response.body, { + status: response.status, + headers: responseHeaders, + }) +} + +export const GET = forward +export const POST = forward diff --git a/app/api/questions/route.ts b/app/api/questions/route.ts new file mode 100644 index 0000000..48eb9ee --- /dev/null +++ b/app/api/questions/route.ts @@ -0,0 +1,87 @@ +// ============================================================ +// GET /api/questions — List questions (optionally filter by mode) +// POST /api/questions — Create a new question +// ============================================================ + +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getUserFromRequest } from "@/lib/auth"; +import { GameMode, Difficulty } from "@prisma/client"; + +export async function GET(req: NextRequest) { + const payload = getUserFromRequest(req); + if (!payload) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const mode = searchParams.get("mode") as GameMode | null; + const difficulty = searchParams.get("difficulty") as Difficulty | null; + + try { + const questions = await prisma.question.findMany({ + where: { + ...(mode ? { mode } : {}), + ...(difficulty ? { difficulty } : {}), + }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json({ questions }); + } catch (error) { + console.error("Questions list error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + const payload = getUserFromRequest(req); + if (!payload) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + try { + const body = await req.json(); + const { + title, + description, + difficulty = "MEDIUM", + mode = "FASTEST", + testCases, + brokenCode, + brokenLanguage, + expectedOutput, + } = body; + + if (!title || !description) { + return NextResponse.json( + { error: "title and description are required" }, + { status: 400 } + ); + } + + const question = await prisma.question.create({ + data: { + title, + description, + difficulty: difficulty as Difficulty, + mode: mode as GameMode, + testCases: testCases || null, + brokenCode: brokenCode || null, + brokenLanguage: brokenLanguage || null, + expectedOutput: expectedOutput || null, + }, + }); + + return NextResponse.json({ question }, { status: 201 }); + } catch (error) { + console.error("Question create error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/queue/route.ts b/app/api/queue/route.ts new file mode 100644 index 0000000..4c5a9df --- /dev/null +++ b/app/api/queue/route.ts @@ -0,0 +1,218 @@ +// ============================================================ +// POST /api/queue — Join the matchmaking queue +// DELETE /api/queue — Leave the queue +// GET /api/queue — Check queue status +// ============================================================ +// When 8 players are in the queue, a match is automatically created. + +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getUserFromRequest } from "@/lib/auth"; +import { GameMode } from "@prisma/client"; + +const PLAYERS_PER_MATCH = 8; + +// ---- Join Queue ---- +export async function POST(req: NextRequest) { + const payload = getUserFromRequest(req); + if (!payload) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + try { + // Check if already in queue + const existing = await prisma.queueEntry.findFirst({ + where: { userId: payload.userId, active: true }, + }); + + if (existing) { + return NextResponse.json( + { message: "Already in queue", queueEntry: existing }, + { status: 200 } + ); + } + + // Check if already in an active match + const activeMatch = await prisma.matchPlayer.findFirst({ + where: { + userId: payload.userId, + match: { status: { in: ["WAITING", "IN_PROGRESS"] } }, + }, + include: { match: true }, + }); + + if (activeMatch) { + return NextResponse.json( + { + message: "Already in an active match", + matchId: activeMatch.matchId, + }, + { status: 200 } + ); + } + + // Add to queue + const queueEntry = await prisma.queueEntry.create({ + data: { userId: payload.userId }, + }); + + // Check if we have enough players to start a match + const match = await tryCreateMatch(); + + if (match) { + return NextResponse.json( + { + message: "Match found!", + matchId: match.id, + status: "matched", + }, + { status: 201 } + ); + } + + // Count current queue size + const queueSize = await prisma.queueEntry.count({ + where: { active: true }, + }); + + return NextResponse.json( + { + message: "Added to queue", + queueEntry, + queueSize, + playersNeeded: PLAYERS_PER_MATCH - queueSize, + status: "waiting", + }, + { status: 201 } + ); + } catch (error) { + console.error("Queue join error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// ---- Leave Queue ---- +export async function DELETE(req: NextRequest) { + const payload = getUserFromRequest(req); + if (!payload) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + try { + await prisma.queueEntry.updateMany({ + where: { userId: payload.userId, active: true }, + data: { active: false }, + }); + + return NextResponse.json({ message: "Left queue" }); + } catch (error) { + console.error("Queue leave error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// ---- Queue Status ---- +export async function GET(req: NextRequest) { + const payload = getUserFromRequest(req); + if (!payload) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + try { + const myEntry = await prisma.queueEntry.findFirst({ + where: { userId: payload.userId, active: true }, + }); + + const queueSize = await prisma.queueEntry.count({ + where: { active: true }, + }); + + return NextResponse.json({ + inQueue: !!myEntry, + queueSize, + playersNeeded: Math.max(0, PLAYERS_PER_MATCH - queueSize), + }); + } catch (error) { + console.error("Queue status error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// ---- Helper: Try to form a match from queued players ---- + +async function tryCreateMatch() { + // Get the first N players (FIFO order) + const queueEntries = await prisma.queueEntry.findMany({ + where: { active: true }, + orderBy: { joinedAt: "asc" }, + take: PLAYERS_PER_MATCH, + include: { user: true }, + }); + + if (queueEntries.length < PLAYERS_PER_MATCH) { + return null; + } + + // Pick random game mode + const modes: GameMode[] = ["FASTEST", "SHORTEST", "REVERSE", "DEBUGGING"]; + const mode = modes[Math.floor(Math.random() * modes.length)]; + + // Pick random questions for this mode (1-3 questions) + const questionCount = Math.floor(Math.random() * 3) + 1; + const questions = await prisma.question.findMany({ + where: { mode }, + take: questionCount, + }); + + // Fallback: pick any questions if none exist for this mode + const finalQuestions = + questions.length > 0 + ? questions + : await prisma.question.findMany({ take: questionCount }); + + // Create the match with players and questions in a transaction + const match = await prisma.$transaction(async (tx) => { + const newMatch = await tx.match.create({ + data: { + mode, + status: "WAITING", + players: { + create: queueEntries.map((entry) => ({ + userId: entry.userId, + })), + }, + questions: { + create: finalQuestions.map((q, i) => ({ + questionId: q.id, + order: i, + })), + }, + }, + include: { + players: { include: { user: { select: { id: true, username: true, displayName: true, skillTier: true } } } }, + questions: { include: { question: true } }, + }, + }); + + // Deactivate queue entries for matched players + await tx.queueEntry.updateMany({ + where: { + id: { in: queueEntries.map((e) => e.id) }, + }, + data: { active: false }, + }); + + return newMatch; + }); + + return match; +} diff --git a/app/api/users/[userId]/route.ts b/app/api/users/[userId]/route.ts new file mode 100644 index 0000000..aa46db5 --- /dev/null +++ b/app/api/users/[userId]/route.ts @@ -0,0 +1,106 @@ +// ============================================================ +// GET /api/users/:userId — Fetch user profile by id +// ============================================================ + +import { NextRequest, NextResponse } from "next/server" +import { prisma } from "@/lib/db" + +export async function GET( + _req: NextRequest, + { params }: { params: { userId: string } }, +) { + const resolvedParams = await Promise.resolve(params) + const { userId } = resolvedParams + + if (!userId) { + return NextResponse.json({ error: "userId is required" }, { status: 400 }) + } + + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + username: true, + email: true, + displayName: true, + title: true, + skillTier: true, + elo: true, + totalWins: true, + totalMatches: true, + totalXP: true, + streak: true, + createdAt: true, + }, + }) + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }) + } + + const recentPlayers = await prisma.matchPlayer.findMany({ + where: { userId }, + include: { + match: { + select: { + id: true, + mode: true, + endedAt: true, + createdAt: true, + players: { + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + }, + }, + }, + }, + }, + }, + }, + orderBy: { joinedAt: "desc" }, + take: 5, + }) + + const recentMatches = recentPlayers.map((player) => { + const opponent = player.match.players.find( + (p) => p.userId !== userId, + ) + const opponentName = + opponent?.user.displayName || opponent?.user.username || "Unknown" + + return { + matchId: player.match.id, + opponent: opponentName, + result: player.result ?? "PENDING", + mode: player.match.mode, + xpDelta: player.xpDelta, + endedAt: player.match.endedAt ?? player.match.createdAt, + } + }) + + const winRate = + user.totalMatches > 0 + ? Math.round((user.totalWins / user.totalMatches) * 100) + : 0 + + return NextResponse.json({ + user, + stats: { + battles: user.totalMatches, + winRate, + xp: user.totalXP, + streak: user.streak, + elo: user.elo, + }, + recentMatches, + }) + } catch (error) { + console.error("User fetch error:", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/app/arena/lobby/page.tsx b/app/arena/lobby/page.tsx new file mode 100644 index 0000000..bed5eda --- /dev/null +++ b/app/arena/lobby/page.tsx @@ -0,0 +1,405 @@ +"use client" + +import { useEffect, useMemo, useRef, useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import { Terminal, ArrowLeft, Users, PlusCircle } from "lucide-react" +import { io, type Socket } from "socket.io-client" +import { getAuthHeaders } from "@/lib/client-auth" + +type LobbyPlayer = { + userId: string + user: { + id: string + username: string + displayName: string | null + elo: number + } +} + +type LobbyMatch = { + id: string + status: string + maxPlayers: number + players: LobbyPlayer[] +} + +const sampleLobbies: LobbyMatch[] = [ + { + id: "sample-1", + status: "WAITING", + maxPlayers: 3, + players: [ + { + userId: "u1", + user: { id: "u1", username: "n3x_byte", displayName: "n3x_byte", elo: 2103 }, + }, + ], + }, + { + id: "sample-2", + status: "WAITING", + maxPlayers: 3, + players: [ + { + userId: "u2", + user: { id: "u2", username: "c0d3_wr4ith", displayName: "c0d3_wr4ith", elo: 2241 }, + }, + { + userId: "u3", + user: { id: "u3", username: "z3r0_day", displayName: "z3r0_day", elo: 1956 }, + }, + ], + }, + { + id: "sample-3", + status: "WAITING", + maxPlayers: 3, + players: [], + }, +] + +export default function LobbyPage() { + const router = useRouter() + const [available, setAvailable] = useState([]) + const [match, setMatch] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const [isSocketConnected, setIsSocketConnected] = useState(false) + const socketRef = useRef(null) + + const playersCount = match?.players.length ?? 0 + const lobbyReady = match?.status === "IN_PROGRESS" + + useEffect(() => { + let active = true + + const initSocket = async () => { + await fetch("/api/socket", { headers: getAuthHeaders() }) + const socket = io({ path: "/api/socket" }) + socketRef.current = socket + + socket.on("connect", () => { + if (!active) return + setIsSocketConnected(true) + }) + + socket.on("disconnect", () => { + if (!active) return + setIsSocketConnected(false) + }) + + socket.on("lobby:list", (matches: LobbyMatch[]) => { + if (!active) return + setAvailable(matches) + }) + + socket.on("lobby:update", (updated: LobbyMatch) => { + if (!active || !updated) return + setMatch((prev) => (prev?.id === updated.id ? updated : prev)) + setAvailable((prev) => { + const exists = prev.some((m) => m.id === updated.id) + if (updated.status !== "WAITING") { + return prev.filter((m) => m.id !== updated.id) + } + if (exists) { + return prev.map((m) => (m.id === updated.id ? updated : m)) + } + return [updated, ...prev] + }) + }) + + socket.on("lobby:started", (payload: { matchId: string }) => { + if (!active) return + setMatch((prev) => { + if (payload?.matchId && prev?.id === payload.matchId) { + router.push(`/arena?match=${payload.matchId}`) + } + return prev + }) + }) + + socket.on("lobby:deleted", (payload: { matchId: string }) => { + if (!active || !payload?.matchId) return + setAvailable((prev) => prev.filter((lobby) => lobby.id !== payload.matchId)) + setMatch((prev) => (prev?.id === payload.matchId ? null : prev)) + }) + } + + initSocket() + + return () => { + active = false + socketRef.current?.disconnect() + socketRef.current = null + } + }, [router]) + + const refreshAvailable = async () => { + try { + const res = await fetch("/api/match/available", { + headers: getAuthHeaders(), + }) + if (!res.ok) return + const payload = await res.json().catch(() => ({})) + if (payload?.matches) { + setAvailable(payload.matches as LobbyMatch[]) + } + } catch { + // ignore + } + } + + const refreshMatch = async (matchId: string) => { + try { + const res = await fetch(`/api/match/${matchId}`, { + headers: getAuthHeaders(), + }) + if (!res.ok) return + const payload = await res.json().catch(() => ({})) + if (payload?.match) { + setMatch(payload.match as LobbyMatch) + } + } catch { + // ignore + } + } + + useEffect(() => { + refreshAvailable() + }, []) + + useEffect(() => { + if (isSocketConnected) return + const interval = setInterval(() => { + refreshAvailable() + if (match?.id) { + refreshMatch(match.id) + } + }, 4000) + return () => clearInterval(interval) + }, [isSocketConnected, match?.id]) + + useEffect(() => { + if (lobbyReady && match?.id) { + router.push(`/arena?match=${match.id}`) + } + }, [lobbyReady, match?.id, router]) + + const handleCreate = async () => { + setError(null) + setLoading(true) + try { + const res = await fetch("/api/match/create", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getAuthHeaders(), + }, + body: JSON.stringify({}), + }) + const payload = await res.json().catch(() => ({})) + if (!res.ok) { + setError(payload?.error || "Failed to create contest.") + return + } + if (payload?.match) { + setMatch(payload.match as LobbyMatch) + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create contest.") + } finally { + setLoading(false) + } + } + + const handleJoin = async (matchId: string) => { + setError(null) + setLoading(true) + try { + const res = await fetch("/api/match/join", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getAuthHeaders(), + }, + body: JSON.stringify({ matchId }), + }) + const payload = await res.json().catch(() => ({})) + if (!res.ok) { + setError(payload?.error || "Failed to join contest.") + return + } + if (payload?.match) { + setMatch(payload.match as LobbyMatch) + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to join contest.") + } finally { + setLoading(false) + } + } + + const statusLabel = match?.status ?? "IDLE" + + const playerRows = useMemo(() => { + if (!match?.players?.length) return [] + return match.players.map((p) => ({ + id: p.userId, + name: p.user.displayName || p.user.username, + elo: p.user.elo, + })) + }, [match?.players]) + + const listIsSample = available.length === 0 + const displayLobbies = listIsSample ? sampleLobbies : available + + return ( +
+
+ + + Back to Home + + +
+
+
+ +
+
+

+ Arena Lobby +

+

+ Create a contest or join an open lobby. Max 3 players. +

+
+
+ + {!match && ( +
+
+
+ + Create Contest +
+

+ Spin up a lobby and wait for up to 2 opponents. +

+ +
+ +
+
+ + Available Contests +
+

+ Pick a lobby to join. List updates every few seconds. +

+
+ {listIsSample && ( +

+ No open contests yet. Sample lobbies shown below. +

+ )} + {displayLobbies.map((lobby) => { + const maxPlayers = lobby.maxPlayers ?? 3 + const isFull = lobby.players.length >= maxPlayers + return ( +
+
+ + #{lobby.id.slice(0, 6).toUpperCase()} + + + {lobby.players.length}/{maxPlayers} players + +
+ +
+ ) + })} +
+
+
+ )} + + {match && ( +
+
+
+

+ Lobby +

+

+ #{match.id.slice(0, 6).toUpperCase()} +

+
+
+

+ Status +

+

+ {statusLabel} +

+
+
+ +
+ + Players {playersCount}/{match.maxPlayers} +
+ +
+ {playerRows.length === 0 && ( +

+ Waiting for players to join... +

+ )} + {playerRows.map((player) => ( +
+ + {player.name} + + + {player.elo} ELO + +
+ ))} +
+
+ )} + + {error && ( +

+ {error} +

+ )} +
+
+
+ ) +} diff --git a/app/arena/page.tsx b/app/arena/page.tsx new file mode 100644 index 0000000..2a5c2df --- /dev/null +++ b/app/arena/page.tsx @@ -0,0 +1,612 @@ +"use client" + +import { useEffect, useMemo, useRef, useState } from "react" +import { useSearchParams } from "next/navigation" +import { CodeEditor } from "@/components/arena/code-editor" +import { UsersPanel } from "@/components/arena/users-panel" +import { PointsPanel } from "@/components/arena/points-panel" +import { Terminal, ArrowLeft } from "lucide-react" +import Link from "next/link" +import { io, type Socket } from "socket.io-client" +import { getAuthHeaders, getCachedUserId } from "@/lib/client-auth" + +type MatchQuestion = { + questionId: string + order: number + question: { + id: string + title: string + description: string + difficulty: "EASY" | "MEDIUM" | "HARD" + testCases?: Array<{ input: string; expectedOutput: string }> + hiddenTestCases?: Array<{ input: string; expectedOutput: string }> + } +} + +type MatchSubmission = { + questionId: string + testsPassed: number + testsTotal: number +} + +type MatchPlayer = { + userId: string + rank: number | null + user: { + id: string + username: string + displayName: string | null + elo: number + } +} + +type MatchDetails = { + id: string + status: string + mode: string + players: MatchPlayer[] + questions: MatchQuestion[] + submissions: MatchSubmission[] +} + +const difficultyPoints: Record = { + EASY: 150, + MEDIUM: 200, + HARD: 250, +} + +const playerColors = [ + "hsl(120 100% 50%)", + "hsl(45 100% 50%)", + "hsl(200 100% 50%)", + "hsl(0 80% 55%)", + "hsl(280 80% 60%)", +] + +const formatDifficulty = (difficulty?: string) => { + if (!difficulty) return undefined + return difficulty[0] + difficulty.slice(1).toLowerCase() +} + +export default function ArenaPage() { + const searchParams = useSearchParams() + const matchId = searchParams.get("match") + const [match, setMatch] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [isSubmittingAll, setIsSubmittingAll] = useState(false) + const [submitAllError, setSubmitAllError] = useState(null) + const [currentUserId, setCurrentUserId] = useState(null) + const [activeQuestionId, setActiveQuestionId] = useState(null) + const [seenQuestionIds, setSeenQuestionIds] = useState([]) + const [codeForBroadcast, setCodeForBroadcast] = useState("") + const [currentLanguage, setCurrentLanguage] = useState("python") + const socketRef = useRef(null) + const broadcastTimer = useRef(null) + const [activityByUser, setActivityByUser] = useState>({}) + const [activityTick, setActivityTick] = useState(0) + const codingWindowMs = 5000 + + useEffect(() => { + if (typeof window === "undefined") return + setCurrentUserId(getCachedUserId()) + }, []) + + useEffect(() => { + if (!matchId) return + let active = true + + const initSocket = async () => { + await fetch("/api/socket", { headers: getAuthHeaders() }) + const socket = io({ path: "/api/socket" }) + socketRef.current = socket + + socket.on("connect", () => { + if (!active) return + }) + + socket.on( + "arena:code", + (payload: { matchId: string; userId: string }) => { + if (!active || payload.matchId !== matchId) return + setActivityByUser((prev) => ({ + ...prev, + [payload.userId]: Date.now(), + })) + }, + ) + } + + initSocket() + + return () => { + active = false + socketRef.current?.disconnect() + socketRef.current = null + } + }, [matchId]) + + useEffect(() => { + const interval = window.setInterval(() => { + setActivityTick(Date.now()) + }, 1500) + return () => window.clearInterval(interval) + }, []) + + useEffect(() => { + if (!socketRef.current || !matchId || !currentUserId || !activeQuestionId) { + return + } + + if (broadcastTimer.current) { + window.clearTimeout(broadcastTimer.current) + } + + broadcastTimer.current = window.setTimeout(() => { + socketRef.current?.emit("arena:code", { + matchId, + userId: currentUserId, + questionId: activeQuestionId, + code: codeForBroadcast, + }) + }, 400) + + return () => { + if (broadcastTimer.current) { + window.clearTimeout(broadcastTimer.current) + } + } + }, [activeQuestionId, codeForBroadcast, currentUserId, matchId]) + + useEffect(() => { + if (!currentUserId) return + setActivityByUser((prev) => ({ + ...prev, + [currentUserId]: Date.now(), + })) + }, [codeForBroadcast, currentUserId]) + + useEffect(() => { + if (!matchId) return + let active = true + + const loadMatch = async () => { + setIsLoading(true) + setError(null) + try { + const res = await fetch(`/api/match/${matchId}`, { + headers: getAuthHeaders(), + }) + const payload = await res.json().catch(() => ({})) + if (!res.ok) { + throw new Error(payload?.error || "Failed to load match.") + } + if (!active) return + setMatch(payload.match as MatchDetails) + } catch (err) { + if (!active) return + setError(err instanceof Error ? err.message : "Failed to load match.") + } finally { + if (active) setIsLoading(false) + } + } + + loadMatch() + + return () => { + active = false + } + }, [matchId]) + + useEffect(() => { + if (!match?.questions?.length) { + setActiveQuestionId(null) + setSeenQuestionIds([]) + return + } + const firstId = match.questions[0].questionId + setActiveQuestionId(firstId) + setSeenQuestionIds([firstId]) + }, [match?.id]) + + useEffect(() => { + if (!activeQuestionId) return + setSeenQuestionIds((prev) => + prev.includes(activeQuestionId) ? prev : [...prev, activeQuestionId], + ) + }, [activeQuestionId, match?.questions]) + + const submissionStatus = useMemo(() => { + const attempted = new Set() + const solved = new Set() + if (match?.submissions && match?.questions) { + const titleToId = new Map( + match.questions.map((q) => [q.question.title, q.questionId]), + ) + match.submissions.forEach((s) => { + const key = titleToId.get(s.questionId) ?? s.questionId + attempted.add(key) + if (s.testsTotal > 0 && s.testsPassed === s.testsTotal) { + solved.add(key) + } + }) + } + return { attempted, solved } + }, [match?.questions, match?.submissions]) + + const totalQuestions = match?.questions?.length ?? 0 + const solvedCount = useMemo(() => { + if (!match) return 0 + return match.questions.reduce((count, mq) => { + if (submissionStatus.solved.has(mq.questionId)) { + return count + 1 + } + return count + }, 0) + }, [match, submissionStatus]) + + const users = useMemo(() => { + if (!match) return undefined + const now = activityTick || Date.now() + return match.players.map((player, index) => { + const display = player.user.displayName || player.user.username + const avatar = display?.trim()?.[0]?.toUpperCase() || "U" + const isCurrent = currentUserId === player.userId + const lastActive = activityByUser[player.userId] + const status = + lastActive && now - lastActive < codingWindowMs ? "coding" : "idle" + return { + id: player.userId, + name: display, + avatar, + status: status as "coding" | "idle", + rank: player.rank ?? index + 1, + rating: player.user.elo, + solved: isCurrent ? solvedCount : 0, + total: totalQuestions, + color: playerColors[index % playerColors.length], + } + }) + }, [activityByUser, activityTick, codingWindowMs, currentUserId, match, solvedCount, totalQuestions]) + + const currentPlayerName = useMemo(() => { + if (!match || !currentUserId) return null + const current = match.players.find((player) => player.userId === currentUserId) + return current?.user.displayName || current?.user.username || null + }, [currentUserId, match]) + + const scoreboardQuestions = useMemo(() => { + if (!match) return undefined + return match.questions.map((mq, index) => { + let status: "solved" | "attempted" | "seen" | "locked" = "locked" + if (submissionStatus.solved.has(mq.questionId)) { + status = "solved" + } else if (submissionStatus.attempted.has(mq.questionId)) { + status = "attempted" + } else if (seenQuestionIds.includes(mq.questionId)) { + status = "seen" + } + + const maxPoints = + difficultyPoints[mq.question.difficulty] ?? + difficultyPoints.MEDIUM + const points = status === "solved" ? maxPoints : 0 + + return { + id: index + 1, + questionId: mq.questionId, + title: mq.question.title, + status, + points, + maxPoints, + bonuses: [], + } + }) + }, [match, seenQuestionIds, submissionStatus]) + + const activeQuestion = + match?.questions.find((q) => q.questionId === activeQuestionId)?.question ?? + match?.questions?.[0]?.question + const activeExamples = Array.isArray(activeQuestion?.testCases) + ? activeQuestion?.testCases + : [] + const activeHiddenTests = Array.isArray(activeQuestion?.hiddenTestCases) + ? activeQuestion?.hiddenTestCases + : [] + const problem = activeQuestion + ? { + title: activeQuestion.title, + description: activeQuestion.description, + difficulty: formatDifficulty(activeQuestion.difficulty), + tag: `Q1 / ${totalQuestions || 1}`, + examples: activeExamples.slice(0, 2).map((tc) => ({ + input: String(tc.input), + output: String(tc.expectedOutput), + })), + hiddenTests: activeHiddenTests.map((tc) => ({ + input: String(tc.input), + output: String(tc.expectedOutput), + })), + constraints: [], + } + : undefined + + return ( +
+ {/* Top bar */} +
+
+ + + +
+ + + CnC Arena + +
+
+
+
+
+ + Live + +
+ + Mode: Code from Scratch + + {currentPlayerName && ( + + {currentPlayerName} + + )} + + Y + +
+
+ + {error && ( +
+ {error} +
+ )} + {submitAllError && ( +
+ {submitAllError} +
+ )} + + {/* Main content area */} +
+ {/* Code editor - center/left */} +
+ setCodeForBroadcast(next)} + onLanguageChange={(next) => setCurrentLanguage(next)} + onSubmissionRecorded={({ questionId, testsPassed, testsTotal }) => { + setMatch((prev) => { + if (!prev) return prev + const existing = prev.submissions.find( + (s) => s.questionId === questionId, + ) + const nextSubmission = { + questionId, + testsPassed, + testsTotal, + } + const submissions = existing + ? prev.submissions.map((s) => + s.questionId === questionId ? nextSubmission : s, + ) + : [...prev.submissions, nextSubmission] + + const ordered = [...prev.questions].sort( + (a, b) => a.order - b.order, + ) + const currentIndex = ordered.findIndex( + (q) => q.questionId === questionId, + ) + const attempted = new Set( + submissions.map((s) => s.questionId), + ) + + let nextId: string | null = null + for (let i = currentIndex + 1; i < ordered.length; i += 1) { + if (!attempted.has(ordered[i].questionId)) { + nextId = ordered[i].questionId + break + } + } + if (!nextId) { + for (let i = 0; i < ordered.length; i += 1) { + if (!attempted.has(ordered[i].questionId)) { + nextId = ordered[i].questionId + break + } + } + } + + if (nextId) { + setActiveQuestionId(nextId) + } else { + if (matchId) { + window.location.href = `/arena/spectate?match=${matchId}` + } else { + window.location.href = "/arena/spectate" + } + } + + return { ...prev, submissions } + }) + }} + /> + {isLoading && ( +
+ Loading match... +
+ )} +
+ + {/* Right sidebar: Users + Points */} +
+ + 0 + ? scoreboardQuestions + : undefined + } + activeQuestionId={activeQuestionId} + onSelectQuestion={(questionId) => setActiveQuestionId(questionId)} + actionLabel="Submit All Codes" + actionDisabled={isSubmittingAll} + actionLoading={isSubmittingAll} + onAction={async () => { + if (!matchId || !match) { + setSubmitAllError("Match not loaded.") + return + } + setSubmitAllError(null) + setIsSubmittingAll(true) + try { + let codeCache: Record = {} + let userName = "" + if (typeof window !== "undefined") { + if (activeQuestionId && activeQuestion?.title) { + window.localStorage.setItem( + activeQuestion.title, + codeForBroadcast, + ) + } + + codeCache = (match.questions || []).reduce( + (acc, item) => { + const title = item.question.title + acc[title] = window.localStorage.getItem(title) ?? "" + return acc + }, + {} as Record, + ) + + const currentPlayer = match.players.find( + (player) => player.userId === currentUserId, + ) + userName = + currentPlayer?.user.displayName || + currentPlayer?.user.username || + "" + + match.questions.forEach((item) => { + window.localStorage.removeItem(item.question.title) + }) + if (currentUserId) { + window.localStorage.setItem("cnc.userId", currentUserId) + window.sessionStorage.setItem("cnc.userId", currentUserId) + } + if (userName) { + window.localStorage.setItem("cnc.userName", userName) + window.sessionStorage.setItem("cnc.userName", userName) + } + } + + const ordered = [...match.questions].sort( + (a, b) => a.order - b.order, + ) + const submissions: MatchSubmission[] = [] + const failures: string[] = [] + + const placeholderForLanguage = (lang: string) => { + const key = lang.toLowerCase() + if (key.includes("python")) return "# no code submitted\n" + if (key.includes("javascript") || key === "js") + return "// no code submitted\n" + if (key.includes("typescript") || key === "ts") + return "// no code submitted\n" + if (key === "java") + return "public class Main { public static void main(String[] args) {} }\n" + if (key.includes("c++") || key.includes("cpp")) + return "#include \nint main(){return 0;}\n" + if (key === "c") return "#include \nint main(){return 0;}\n" + if (key.includes("go")) + return "package main\nfunc main(){}\n" + if (key.includes("rust")) + return "fn main() {}\n" + return "// no code submitted\n" + } + + for (const item of ordered) { + const title = item.question.title + const code = + typeof window !== "undefined" ? codeCache[title] ?? "" : "" + const finalCode = + code.trim().length > 0 + ? code + : placeholderForLanguage(currentLanguage || "python") + + const res = await fetch(`/api/match/${matchId}/submit`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getAuthHeaders(), + }, + body: JSON.stringify({ + questionId: item.questionId, + code: finalCode, + language: currentLanguage || "python", + }), + }) + const payload = await res.json().catch(() => ({})) + if (!res.ok) { + failures.push( + `${title}: ${payload?.error || "submit failed"}`, + ) + continue + } + submissions.push({ + questionId: item.questionId, + testsPassed: payload?.submission?.testsPassed ?? 0, + testsTotal: payload?.submission?.testsTotal ?? 0, + }) + } + + if (submissions.length > 0) { + setMatch((prev) => + prev ? { ...prev, submissions } : prev, + ) + } + + if (failures.length > 0) { + setSubmitAllError( + `Some submissions failed: ${failures.join(", ")}`, + ) + return + } + + window.location.href = `/arena/submitted?match=${matchId}` + } catch (err) { + setSubmitAllError( + err instanceof Error ? err.message : "Submit all failed.", + ) + } finally { + setIsSubmittingAll(false) + } + }} + /> +
+
+
+ ) +} diff --git a/app/arena/spectate/page.tsx b/app/arena/spectate/page.tsx new file mode 100644 index 0000000..32e936f --- /dev/null +++ b/app/arena/spectate/page.tsx @@ -0,0 +1,238 @@ +"use client" + +import { useEffect, useMemo, useRef, useState } from "react" +import { useSearchParams } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, Terminal, Eye } from "lucide-react" +import { io, type Socket } from "socket.io-client" +import { getAuthHeaders } from "@/lib/client-auth" + +type MatchQuestion = { + questionId: string + question: { + title: string + } +} + +type MatchPlayer = { + userId: string + user: { + id: string + username: string + displayName: string | null + elo: number + } +} + +type MatchDetails = { + id: string + players: MatchPlayer[] + questions: MatchQuestion[] +} + +type CodePayload = { + matchId: string + userId: string + questionId?: string + code?: string +} + +export default function SpectatePage() { + const searchParams = useSearchParams() + const matchId = searchParams.get("match") + const [match, setMatch] = useState(null) + const [error, setError] = useState(null) + const [selectedUserId, setSelectedUserId] = useState(null) + const [codeByUser, setCodeByUser] = useState< + Record + >({}) + const socketRef = useRef(null) + + useEffect(() => { + if (!matchId) return + let active = true + + const loadMatch = async () => { + try { + const res = await fetch(`/api/match/${matchId}`, { + headers: getAuthHeaders(), + }) + const payload = await res.json().catch(() => ({})) + if (!res.ok) { + throw new Error(payload?.error || "Failed to load match.") + } + if (!active) return + setMatch(payload.match as MatchDetails) + const first = payload.match?.players?.[0]?.userId ?? null + setSelectedUserId((prev) => prev ?? first) + } catch (err) { + if (!active) return + setError(err instanceof Error ? err.message : "Failed to load match.") + } + } + + loadMatch() + + return () => { + active = false + } + }, [matchId]) + + useEffect(() => { + if (!matchId) return + let active = true + + const initSocket = async () => { + await fetch("/api/socket", { headers: getAuthHeaders() }) + const socket = io({ path: "/api/socket" }) + socketRef.current = socket + + socket.on("arena:code", (payload: CodePayload) => { + if (!active || payload.matchId !== matchId) return + setCodeByUser((prev) => ({ + ...prev, + [payload.userId]: { + code: payload.code ?? "", + questionId: payload.questionId, + }, + })) + }) + } + + initSocket() + + return () => { + active = false + socketRef.current?.disconnect() + socketRef.current = null + } + }, [matchId]) + + const selectedPlayer = match?.players.find( + (player) => player.userId === selectedUserId, + ) + + const selectedName = selectedPlayer + ? selectedPlayer.user.displayName || selectedPlayer.user.username + : "Select a player" + + const selectedCode = selectedUserId + ? codeByUser[selectedUserId]?.code ?? "" + : "" + + const selectedQuestionTitle = useMemo(() => { + if (!selectedUserId || !match) return "Waiting for activity..." + const questionId = codeByUser[selectedUserId]?.questionId + if (!questionId) return "Waiting for activity..." + const question = match.questions.find((q) => q.questionId === questionId) + return question?.question.title ?? "Working on a question..." + }, [codeByUser, match, selectedUserId]) + + return ( +
+
+ + + Back to Lobby + + +
+
+
+ +
+
+

+ Spectate Arena +

+

+ Watch any player in real time. +

+
+
+ + {error && ( +
+ {error} +
+ )} + + {!matchId && ( +
+ No match selected. +
+ )} + + {matchId && ( +
+
+
+ + Players +
+
+ {(match?.players ?? []).map((player) => { + const display = + player.user.displayName || player.user.username + const isActive = player.userId === selectedUserId + return ( + + ) + })} + {match && match.players.length === 0 && ( +

+ No players connected. +

+ )} +
+
+ +
+
+
+

+ Watching +

+

+ {selectedName} +

+
+ + Live + +
+ +
+ {selectedQuestionTitle} +
+ +
+
+                    {selectedCode || "Waiting for code updates..."}
+                  
+
+
+
+ )} +
+
+
+ ) +} diff --git a/app/arena/submitted/page.tsx b/app/arena/submitted/page.tsx new file mode 100644 index 0000000..57ec762 --- /dev/null +++ b/app/arena/submitted/page.tsx @@ -0,0 +1,37 @@ +"use client" + +import Link from "next/link" +import { ArrowLeft, Terminal } from "lucide-react" + +export default function ArenaSubmittedPage() { + return ( +
+
+
+ +
+

+ All Codes Submitted +

+

+ Your solutions have been recorded. You can head back to your profile. +

+ + + Go to Profile + + + + + Back to Lobby + +
+
+ ) +} diff --git a/app/auth/page.tsx b/app/auth/page.tsx new file mode 100644 index 0000000..b66705a --- /dev/null +++ b/app/auth/page.tsx @@ -0,0 +1,306 @@ +"use client" + +import { Terminal, ArrowLeft } from "lucide-react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { useState } from "react" +import { cacheAuthSession } from "@/lib/client-auth" + +export default function AuthPage() { + const [isLoading, setIsLoading] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [handle, setHandle] = useState("") + const [accessKey, setAccessKey] = useState("") + const [status, setStatus] = useState("Awaiting credentials...") + const [error, setError] = useState(null) + const router = useRouter() + + const cacheUserId = (userId: string, userName?: string, token?: string) => { + cacheAuthSession({ userId, userName, token }) + } + + const handleGoogleSignIn = () => { + setIsLoading(true) + // Mock: In production, redirect to Supabase Google OAuth + setTimeout(() => { + setIsLoading(false) + }, 2000) + } + + const handleSignup = async () => { + setError(null) + + const username = handle.trim() + if (!username) { + setError("Handle is required.") + return + } + if (!accessKey.trim()) { + setError("Access key is required.") + return + } + + const slug = + username + .toLowerCase() + .replace(/[^a-z0-9]+/g, ".") + .replace(/^\.+|\.+$/g, "") || "user" + const email = `${slug}@cnc.local` + + setIsSubmitting(true) + setStatus("Initializing session...") + try { + const response = await fetch("/api/auth/signup", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username, + email, + password: accessKey, + displayName: username, + }), + }) + + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + setStatus("Awaiting credentials...") + setError(payload?.error || "Signup failed.") + return + } + + if (!payload?.user?.id) { + setStatus("Awaiting credentials...") + setError("Signup succeeded but user id was missing.") + return + } + cacheUserId( + payload.user.id, + payload.user.displayName || payload.user.username, + payload.token, + ) + setStatus("Session initialized.") + router.push("/profile") + } catch (err) { + setStatus("Awaiting credentials...") + setError(err instanceof Error ? err.message : "Signup failed.") + } finally { + setIsSubmitting(false) + } + } + + const handleSignIn = async () => { + setError(null) + + const login = handle.trim() + if (!login) { + setError("Handle is required.") + return + } + if (!accessKey.trim()) { + setError("Access key is required.") + return + } + + setIsSubmitting(true) + setStatus("Accessing terminal...") + try { + const response = await fetch("/api/auth/signin", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + login, + password: accessKey, + }), + }) + + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + setStatus("Awaiting credentials...") + setError(payload?.error || "Sign in failed.") + return + } + + if (!payload?.user?.id) { + setStatus("Awaiting credentials...") + setError("Sign in succeeded but user id was missing.") + return + } + cacheUserId( + payload.user.id, + payload.user.displayName || payload.user.username, + payload.token, + ) + setStatus("Access granted.") + router.push("/profile") + } catch (err) { + setStatus("Awaiting credentials...") + setError(err instanceof Error ? err.message : "Sign in failed.") + } finally { + setIsSubmitting(false) + } + } + + return ( +
+ {/* Grid background */} +
+ + {/* Scanline */} +
+
+
+ +
+ {/* Back link */} + + + Back to Home + + + {/* Auth card */} +
+ {/* Header */} +
+
+ +
+
+

+ {"Access Terminal"} +

+

+ {"// authenticate to enter the arena"} +

+
+
+ + {/* Terminal-style status */} +
+
+ {"$"} + {"system.auth --status"} +
+
+ {">"} + {status} + {"_"} +
+
+ + {/* Google Sign In button */} + + + {/* Divider */} +
+
+ {"or"} +
+
+ + {/* Username/Email mock fields */} +
+
+ + setHandle(e.target.value)} + className="w-full rounded-sm border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ + setAccessKey(e.target.value)} + className="w-full rounded-sm border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ + +
+ {error && ( +

+ {error} +

+ )} +
+ + {/* Footer text */} +

+ {"New operative? "} + + {"Request Access"} + +

+
+
+
+ ) +} diff --git a/app/globals.css b/app/globals.css index a2dc41e..8f13be6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,114 @@ @import "tailwindcss"; -:root { - --background: #ffffff; - --foreground: #171717; +@theme { + --color-background: hsl(120 5% 3%); + --color-foreground: hsl(120 100% 85%); + --color-card: hsl(120 5% 5%); + --color-card-foreground: hsl(120 100% 85%); + --color-popover: hsl(120 5% 5%); + --color-popover-foreground: hsl(120 100% 85%); + --color-primary: hsl(120 100% 50%); + --color-primary-foreground: hsl(120 5% 3%); + --color-secondary: hsl(120 5% 10%); + --color-secondary-foreground: hsl(120 100% 75%); + --color-muted: hsl(120 3% 12%); + --color-muted-foreground: hsl(120 10% 50%); + --color-accent: hsl(120 100% 50%); + --color-accent-foreground: hsl(120 5% 3%); + --color-destructive: hsl(0 80% 50%); + --color-destructive-foreground: hsl(0 0% 98%); + --color-border: hsl(120 15% 15%); + --color-input: hsl(120 10% 12%); + --color-ring: hsl(120 100% 50%); + --color-chart-1: hsl(120 100% 50%); + --color-chart-2: hsl(120 80% 40%); + --color-chart-3: hsl(120 60% 30%); + --color-chart-4: hsl(45 100% 50%); + --color-chart-5: hsl(0 80% 50%); + --color-sidebar: hsl(120 5% 5%); + --color-sidebar-foreground: hsl(120 100% 85%); + --color-sidebar-primary: hsl(120 100% 50%); + --color-sidebar-primary-foreground: hsl(120 5% 3%); + --color-sidebar-accent: hsl(120 5% 10%); + --color-sidebar-accent-foreground: hsl(120 100% 85%); + --color-sidebar-border: hsl(120 15% 15%); + --color-sidebar-ring: hsl(120 100% 50%); + --radius-sm: calc(0.375rem - 4px); + --radius-md: calc(0.375rem - 2px); + --radius-lg: 0.375rem; + --radius-xl: calc(0.375rem + 4px); } -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); +* { + border-color: var(--color-border); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +body { + background-color: var(--color-background); + color: var(--color-foreground); } -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +/* Scanline effect */ +@keyframes scanline { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(100vh); } +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +@keyframes pulse-glow { + 0%, 100% { box-shadow: 0 0 5px hsl(120 100% 50% / 0.3); } + 50% { box-shadow: 0 0 20px hsl(120 100% 50% / 0.6), 0 0 40px hsl(120 100% 50% / 0.2); } +} + +@keyframes typing { + from { width: 0; } + to { width: 100%; } +} + +@keyframes flicker { + 0%, 100% { opacity: 1; } + 92% { opacity: 1; } + 93% { opacity: 0.8; } + 94% { opacity: 1; } + 96% { opacity: 0.9; } + 97% { opacity: 1; } +} + +.animate-scanline { + animation: scanline 8s linear infinite; +} + +.animate-blink { + animation: blink 1s step-end infinite; +} + +.animate-pulse-glow { + animation: pulse-glow 2s ease-in-out infinite; +} + +.animate-flicker { + animation: flicker 4s linear infinite; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: hsl(120 5% 5%); +} + +::-webkit-scrollbar-thumb { + background: hsl(120 100% 50% / 0.3); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: hsl(120 100% 50% / 0.5); } diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..0741ec6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,34 +1,31 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; +import React from "react" +import type { Metadata, Viewport } from 'next' +import { JetBrains_Mono } from 'next/font/google' -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); +import './globals.css' -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +const jetbrainsMono = JetBrains_Mono({ + subsets: ['latin'], + variable: '--font-geist-mono', +}) export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; + title: 'CompileNConquer - Competitive Coding Arena', + description: 'A multiplayer competitive coding game that blends problem-solving depth with real-time racing excitement.', +} + +export const viewport: Viewport = { + themeColor: '#00ff41', +} export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode }>) { return ( - - - {children} - + + {children} - ); + ) } diff --git a/app/page.tsx b/app/page.tsx index 295f8fd..69a2aaa 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,65 +1,17 @@ -import Image from "next/image"; +import { Navbar } from "@/components/landing/navbar" +import { Hero } from "@/components/landing/hero" +import { Features } from "@/components/landing/features" +import { GameModes } from "@/components/landing/game-modes" +import { Footer } from "@/components/landing/footer" -export default function Home() { +export default function Page() { return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); +
+ + + + +
+
+ ) } diff --git a/app/profile/page.tsx b/app/profile/page.tsx new file mode 100644 index 0000000..3530ab7 --- /dev/null +++ b/app/profile/page.tsx @@ -0,0 +1,321 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { Terminal, ArrowLeft, Trophy, Zap, Code, Target, Clock, TrendingUp } from "lucide-react" +import Link from "next/link" +import { getAuthHeaders, getCachedUserId } from "@/lib/client-auth" + +type ProfileUser = { + id: string + username: string + email: string + displayName: string | null + title: string + skillTier: string + elo: number + totalWins: number + totalMatches: number + totalXP: number + streak: number + createdAt: string +} + +type ProfileStats = { + battles: number + winRate: number + xp: number + streak: number + elo: number +} + +type RecentMatch = { + matchId: string + opponent: string + result: "WIN" | "LOSS" | "DRAW" | "PENDING" + mode: string + xpDelta: number + endedAt: string +} + +const recentMatches = [ + { opponent: "n3x_byte", result: "WIN", points: "+175", mode: "Scratch", date: "2 hrs ago" }, + { opponent: "c0d3_wr4ith", result: "LOSS", points: "-50", mode: "Debug", date: "5 hrs ago" }, + { opponent: "z3r0_day", result: "WIN", points: "+200", mode: "Reverse", date: "1 day ago" }, + { opponent: "algo_gh0st", result: "WIN", points: "+150", mode: "Scratch", date: "1 day ago" }, + { opponent: "byteB1aster", result: "WIN", points: "+180", mode: "Debug", date: "2 days ago" }, +] + +const toDisplayMatch = (match: RecentMatch) => { + const points = match.xpDelta >= 0 ? `+${match.xpDelta}` : `${match.xpDelta}` + const date = new Date(match.endedAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + return { + opponent: match.opponent, + result: match.result === "PENDING" ? "LOSS" : match.result, + points: `${points} XP`, + mode: match.mode, + date, + } +} + +const skillBreakdown = [ + { skill: "DSA", level: 78, color: "hsl(120 100% 50%)" }, + { skill: "Clean Code", level: 85, color: "hsl(45 100% 50%)" }, + { skill: "Speed", level: 62, color: "hsl(200 100% 50%)" }, + { skill: "Debugging", level: 71, color: "hsl(0 80% 55%)" }, + { skill: "Efficiency", level: 90, color: "hsl(120 80% 60%)" }, +] + +export default function ProfilePage() { + const [user, setUser] = useState(null) + const [stats, setStats] = useState(null) + const [matches, setMatches] = useState([]) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const cachedId = getCachedUserId() + + if (!cachedId) { + setError("No cached user found. Please sign in.") + setLoading(false) + return + } + + const loadProfile = async () => { + try { + const response = await fetch(`/api/users/${cachedId}`, { + headers: getAuthHeaders(), + }) + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + setError(payload?.error || "Failed to load profile.") + setLoading(false) + return + } + setUser(payload.user as ProfileUser) + setStats(payload.stats as ProfileStats) + setMatches((payload.recentMatches as RecentMatch[]) ?? []) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load profile.") + } finally { + setLoading(false) + } + } + + loadProfile() + }, []) + + const derived = useMemo(() => { + const displayName = user?.displayName || user?.username || "you" + const rating = stats?.elo ?? user?.elo ?? 0 + const totalBattles = stats?.battles ?? user?.totalMatches ?? 0 + const wins = user?.totalWins ?? 0 + const losses = Math.max(totalBattles - wins, 0) + const winRate = stats + ? `${stats.winRate}%` + : totalBattles > 0 + ? `${Math.round((wins / totalBattles) * 100)}%` + : "0%" + const joined = user?.createdAt + ? new Date(user.createdAt).toLocaleDateString("en-US", { + month: "short", + year: "numeric", + }) + : "-" + const title = user?.title || "Code Warrior" + const totalXP = stats?.xp ?? user?.totalXP ?? 0 + const streak = stats?.streak ?? user?.streak ?? 0 + + return { + displayName, + rating, + rank: "Unranked", + title, + joined, + totalBattles, + wins, + losses, + winRate, + totalXP, + streak, + } + }, [user]) + + const statCards = [ + { label: "Battles", value: String(derived.totalBattles), icon: Target }, + { label: "Win Rate", value: derived.winRate, icon: TrendingUp }, + { label: "XP", value: derived.totalXP.toLocaleString("en-US"), icon: Zap }, + { label: "Streak", value: String(derived.streak), icon: Trophy }, + ] + + const hasRecentMatches = matches.length > 0 + const displayMatches = hasRecentMatches + ? matches.map(toDisplayMatch) + : recentMatches + + return ( +
+ {/* Top bar */} +
+
+
+ + + +
+ + + Profile Terminal + +
+
+ + Enter Arena + +
+
+ +
+ {loading && ( +
+ Loading profile... +
+ )} + {error && !loading && ( +
+ {error} +
+ )} + {/* Profile header */} +
+
+ {derived.displayName.slice(0, 1).toUpperCase()} +
+
+
+

{derived.displayName}

+ + {derived.title} + +
+
+ ELO: {derived.rating} + Global Rank: {derived.rank} + Joined: {derived.joined} +
+
+
+ + {/* Stats grid */} +
+ {statCards.map((stat) => ( +
+
+ + + {stat.label} + +
+ {stat.value} +
+ ))} +
+ +
+ {/* Skill breakdown */} +
+
+ + + Skill Breakdown + +
+

+ Skill metrics are coming soon. Sample bars shown below. +

+
+ {skillBreakdown.map((s) => ( +
+
+ {s.skill} + {s.level}% +
+
+
+
+
+ ))} +
+
+ + {/* Recent matches */} +
+
+ + + Recent Matches + +
+ {!hasRecentMatches && ( +

+ No matches found yet. Sample history shown below. +

+ )} +
+ {displayMatches.map((match, i) => ( +
+
+ + {match.result} + +
+ + vs {match.opponent} + + {match.mode} +
+
+
+ + {match.points} XP + + {match.date} +
+
+ ))} +
+
+
+
+
+ ) +} diff --git a/backbaord/match.js b/backbaord/match.js new file mode 100644 index 0000000..fd6d54e --- /dev/null +++ b/backbaord/match.js @@ -0,0 +1,313 @@ +import { BackboardClient } from 'backboard-sdk'; + +let _client = null; +function getClient() { + if (!_client) { + _client = new BackboardClient({ + apiKey: process.env.BACKBOARD_API_KEY, + }); + } + return _client; +} + +// ─── HELPERS ──────────────────────────────────────────────────── + +function stripMarkdownFences(text) { + // Remove ```json ... ``` or ``` ... ``` wrappers + return text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim(); +} + +// ─── SYSTEM PROMPTS ───────────────────────────────────────────── + +const PLAYER_THREAD_SYSTEM_PROMPT = `You are a code analysis engine. You receive exactly 3 question-answer pairs. + +For each pair, return ONLY a JSON array of 3 objects with this exact schema: +[ + { "TC": "", "SC": "", "Context": <1 or 0> }, + { "TC": "", "SC": "", "Context": <1 or 0> }, + { "TC": "", "SC": "", "Context": <1 or 0> } +] + +Rules: +- TC = time complexity as Big-O string (e.g. "O(n)", "O(n log n)", "O(1)") +- SC = space complexity as Big-O string +- Context = 1 if the solution logically solves the given question +- Context = 0 if incorrect or unrelated +- If Context = 0, set TC = "O(inf)" and SC = "O(inf)" +- Return ONLY the JSON array. No explanations, no markdown, no extra text.`; + +const COMPARISON_THREAD_SYSTEM_PROMPT = `You are a deterministic grading engine. You receive a JSON object with N players, each having 3 {TC, SC} pairs. + +For each question index (0, 1, 2), independently rank players on TC, then on SC. + +Ranking rules per metric (TC or SC) per question: +- Parse complexities and rank from best (lowest) to worst (highest). +- Assign points based on rank position: best gets N points, next gets N-1, ..., worst gets 1. +- If players are tied on a metric, all tied players receive the same score: the average of the positions they span, floored. If ALL players are equal, each gets floor((N+1)/2) points. +- Any player with "O(inf)" gets 0 points for that metric and is excluded from ranking. + +GrQx = TC_points + SC_points for question x. + +Output ONLY a JSON object: +{ + "Player1": [GrQ1, GrQ2, GrQ3], + "Player2": [GrQ1, GrQ2, GrQ3], + ... +} + +No explanations, no markdown, no extra text. ONLY the JSON object.`; + +// ─── COMPLEXITY RANKING ───────────────────────────────────────── + +const COMPLEXITY_ORDER = [ + 'O(1)', + 'O(log n)', + 'O(sqrt(n))', + 'O(n)', + 'O(n log n)', + 'O(n^2)', + 'O(n^3)', + 'O(2^n)', + 'O(n!)', + 'O(inf)', +]; + +function complexityRank(bigO) { + const normalized = bigO.replace(/\s+/g, '').toLowerCase(); + const idx = COMPLEXITY_ORDER.findIndex( + (c) => c.replace(/\s+/g, '').toLowerCase() === normalized + ); + return idx === -1 ? COMPLEXITY_ORDER.length - 1 : idx; +} + +// ─── DETERMINISTIC COMPARISON (APP-SIDE FALLBACK) ─────────────── + +function deterministicCompare(playersData) { + // playersData: { Player1: [{TC,SC},{TC,SC},{TC,SC}], ... } + const playerNames = Object.keys(playersData); + const N = playerNames.length; + const numQuestions = 3; + + const result = {}; + playerNames.forEach((p) => (result[p] = [])); + + for (let q = 0; q < numQuestions; q++) { + // Rank TC + const tcPoints = rankMetric(playerNames, playersData, q, 'TC', N); + // Rank SC + const scPoints = rankMetric(playerNames, playersData, q, 'SC', N); + + playerNames.forEach((p) => { + result[p].push(tcPoints[p] + scPoints[p]); + }); + } + + return result; +} + +function rankMetric(playerNames, playersData, questionIdx, metric, N) { + const points = {}; + playerNames.forEach((p) => (points[p] = 0)); + + // Separate O(inf) players (0 points) from valid players + const validPlayers = []; + playerNames.forEach((p) => { + const val = playersData[p][questionIdx][metric]; + if (val === 'O(inf)') { + points[p] = 0; + } else { + validPlayers.push({ name: p, value: val, rank: complexityRank(val) }); + } + }); + + if (validPlayers.length === 0) return points; + + // Sort by rank ascending (best first) + validPlayers.sort((a, b) => a.rank - b.rank); + + // Assign points: best gets validPlayers.length points down to 1 + // Handle ties: tied players share the average of their positions + let i = 0; + while (i < validPlayers.length) { + let j = i; + // Find all players tied with the same rank + while (j < validPlayers.length && validPlayers[j].rank === validPlayers[i].rank) { + j++; + } + // Positions i..j-1 are tied + // Points for position k (0-indexed) = N_valid - k (so best=N_valid, worst=1) + // But we use validPlayers.length instead of N so excluded O(inf) don't take slots + const N_valid = validPlayers.length; + let sumPoints = 0; + for (let k = i; k < j; k++) { + sumPoints += N_valid - k; + } + const sharedPoints = Math.floor(sumPoints / (j - i)); + + for (let k = i; k < j; k++) { + points[validPlayers[k].name] = sharedPoints; + } + i = j; + } + + return points; +} + +// ─── MATCH ENGINE ─────────────────────────────────────────────── + +/** + * Creates a match assistant and returns its ID. + */ +async function createMatchAssistant(matchId) { + const assistant = await getClient().createAssistant({ + name: `Match-${matchId}`, + system_prompt: 'Match coordinator assistant', + }); + return assistant.assistantId; +} + +/** + * Creates a player thread within the match assistant. + * Returns { threadId, playerName }. + */ +async function createPlayerThread(assistantId, playerName) { + const thread = await getClient().createThread(assistantId); + return { threadId: thread.threadId, playerName }; +} + +/** + * Creates the comparison thread within the match assistant. + */ +async function createComparisonThread(assistantId) { + const thread = await getClient().createThread(assistantId); + return thread.threadId; +} + +/** + * Sends a player's 3 Q&A pairs to their thread and returns parsed verdict. + * Input: answers = [ {question, answer}, {question, answer}, {question, answer} ] + * Output: [ {TC, SC, Context}, {TC, SC, Context}, {TC, SC, Context} ] + */ +async function evaluatePlayer(threadId, answers) { + const messageContent = JSON.stringify({ + submissions: answers.map((a, i) => ({ + [`Question${i + 1}`]: a.question, + [`Answer${i + 1}`]: a.answer, + })), + }); + + const response = await getClient().addMessage(threadId, { + content: `${PLAYER_THREAD_SYSTEM_PROMPT}\n\nInput:\n${messageContent}`, + llm_provider: 'openai', + model_name: 'gpt-4o', + stream: false, + }); + + const parsed = JSON.parse(stripMarkdownFences(response.content)); + + // Enforce Context=0 → O(inf) rule + return parsed.map((entry) => { + if (entry.Context === 0) { + return { TC: 'O(inf)', SC: 'O(inf)', Context: 0 }; + } + return { TC: entry.TC, SC: entry.SC, Context: entry.Context }; + }); +} + +/** + * Sends aggregated results to comparison thread and returns grades. + * Also runs app-side deterministic comparison as the canonical result. + * + * Input: playerResults = { Player1: [{TC,SC,Context},...], ... } + * Output: { Player1: [GrQ1, GrQ2, GrQ3], ... } + */ +async function compareAndGrade(comparisonThreadId, playerResults) { + // Strip Context field — comparison only needs TC and SC + const comparisonPayload = {}; + for (const [player, results] of Object.entries(playerResults)) { + comparisonPayload[player] = results.map((r) => ({ TC: r.TC, SC: r.SC })); + } + + // App-side deterministic comparison (canonical source of truth) + const deterministicResult = deterministicCompare(comparisonPayload); + + // Also send to comparison thread for cross-validation (optional) + const messageContent = JSON.stringify(comparisonPayload); + + await getClient().addMessage(comparisonThreadId, { + content: `${COMPARISON_THREAD_SYSTEM_PROMPT}\n\nInput:\n${messageContent}`, + llm_provider: 'openai', + model_name: 'gpt-4o', + stream: false, + }); + + // Use deterministic app-side result as the canonical output + return deterministicResult; +} + +// ─── FULL MATCH FLOW ──────────────────────────────────────────── + +/** + * Runs a complete match. + * + * Input: + * matchId: string + * players: [ + * { + * name: "Player1", + * answers: [ + * { question: "...", answer: "..." }, + * { question: "...", answer: "..." }, + * { question: "...", answer: "..." } + * ] + * }, + * ... + * ] + * + * Output: + * { + * assistantId: string, + * playerResults: { Player1: [{TC,SC,Context},...], ... }, + * grades: { Player1: [GrQ1, GrQ2, GrQ3], ... } + * } + */ +async function runMatch(matchId, players) { + // 1. Create match assistant + const assistantId = await createMatchAssistant(matchId); + + // 2. Create player threads + comparison thread in parallel + const playerThreadPromises = players.map((p) => + createPlayerThread(assistantId, p.name) + ); + const comparisonThreadPromise = createComparisonThread(assistantId); + + const playerThreads = await Promise.all(playerThreadPromises); + const comparisonThreadId = await comparisonThreadPromise; + + // 3. Evaluate all players in parallel + const evaluationPromises = players.map((p, i) => + evaluatePlayer(playerThreads[i].threadId, p.answers) + ); + const evaluations = await Promise.all(evaluationPromises); + + // 4. Aggregate results + const playerResults = {}; + players.forEach((p, i) => { + playerResults[p.name] = evaluations[i]; + }); + + // 5. Run comparison + const grades = await compareAndGrade(comparisonThreadId, playerResults); + + return { assistantId, playerResults, grades }; +} + +export { + createMatchAssistant, + createPlayerThread, + createComparisonThread, + evaluatePlayer, + compareAndGrade, + deterministicCompare, + runMatch, +}; diff --git a/backbaord/package.json b/backbaord/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/backbaord/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/backbaord/test/config.js b/backbaord/test/config.js new file mode 100644 index 0000000..c9c9896 --- /dev/null +++ b/backbaord/test/config.js @@ -0,0 +1,5 @@ +const CONFIG = { + MATCH_ID: `match-${Date.now()}`, +}; + +export default CONFIG; diff --git a/backbaord/test/data.js b/backbaord/test/data.js new file mode 100644 index 0000000..18af46f --- /dev/null +++ b/backbaord/test/data.js @@ -0,0 +1,75 @@ +// ─── QUESTIONS ────────────────────────────────────────────────── + +const QUESTIONS = [ + 'Given an array of integers, return indices of the two numbers that add up to a specific target.', + 'Reverse a singly linked list.', + 'Find the contiguous subarray with the largest sum.', +]; + +// ─── PLAYER SUBMISSIONS ───────────────────────────────────────── +// Each player has a name and 3 answers (one per question). +// This is the ONLY input the system needs. + +const PLAYERS = [ + { + name: 'Player1', + answers: [ + { + question: QUESTIONS[0], + answer: + 'function twoSum(nums, target) { const map = {}; for (let i = 0; i < nums.length; i++) { const c = target - nums[i]; if (map[c] !== undefined) return [map[c], i]; map[nums[i]] = i; } }', + }, + { + question: QUESTIONS[1], + answer: + 'function reverseList(head) { let prev = null, curr = head; while (curr) { const next = curr.next; curr.next = prev; prev = curr; curr = next; } return prev; }', + }, + { + question: QUESTIONS[2], + answer: + 'function maxSubArray(nums) { let max = nums[0], cur = nums[0]; for (let i = 1; i < nums.length; i++) { cur = Math.max(nums[i], cur + nums[i]); max = Math.max(max, cur); } return max; }', + }, + ], + }, + { + name: 'Player2', + answers: [ + { + question: QUESTIONS[0], + answer: + 'function twoSum(nums, target) { for (let i = 0; i < nums.length; i++) for (let j = i+1; j < nums.length; j++) if (nums[i]+nums[j] === target) return [i,j]; }', + }, + { + question: QUESTIONS[1], + answer: + 'function reverseList(head) { let prev = null, curr = head; while (curr) { const next = curr.next; curr.next = prev; prev = curr; curr = next; } return prev; }', + }, + { + question: QUESTIONS[2], + answer: 'I have no idea what a subarray is, here is a poem about trees.', + }, + ], + }, + { + name: 'Player3', + answers: [ + { + question: QUESTIONS[0], + answer: + 'function twoSum(nums, target) { nums.sort((a,b) => a-b); let l = 0, r = nums.length-1; while (l < r) { const s = nums[l]+nums[r]; if (s === target) return [l,r]; s < target ? l++ : r--; } }', + }, + { + question: QUESTIONS[1], + answer: + 'function reverseList(head) { if (!head || !head.next) return head; const rest = reverseList(head.next); head.next.next = head; head.next = null; return rest; }', + }, + { + question: QUESTIONS[2], + answer: + 'function maxSubArray(nums) { let max = -Infinity; for (let i = 0; i < nums.length; i++) { let sum = 0; for (let j = i; j < nums.length; j++) { sum += nums[j]; if (sum > max) max = sum; } } return max; }', + }, + ], + }, +]; + +export { QUESTIONS, PLAYERS }; diff --git a/backbaord/test/index.js b/backbaord/test/index.js new file mode 100644 index 0000000..92202d7 --- /dev/null +++ b/backbaord/test/index.js @@ -0,0 +1,92 @@ +/** + * index.js — Match Runner + * + * Runs a full competitive coding match via the Backboard API. + * Input: questions + player answers (defined in data.js) + * Output: AI-evaluated TC/SC/Context + deterministic grades + ranking + * + * Run: node backboard/test/index.js + */ + +import dotenv from 'dotenv'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: resolve(__dirname, '../../.env.local') }); + +import CONFIG from './config.js'; +import { PLAYERS } from './data.js'; +import { runMatch } from '../match.js'; + +// ─── FORMATTING ───────────────────────────────────────────────── + +function divider(label) { + const line = '─'.repeat(56); + console.log(`\n${line}`); + if (label) console.log(` ${label}`); + console.log(line); +} + +function pad(str, len) { + return String(str).padEnd(len); +} + +// ─── MAIN ─────────────────────────────────────────────────────── + +async function main() { + if (!process.env.BACKBOARD_API_KEY) { + console.error('ERROR: BACKBOARD_API_KEY not set. Add it to .env.local'); + process.exit(1); + } + + divider(`MATCH: ${CONFIG.MATCH_ID} | Players: ${PLAYERS.length}`); + console.log(' Calling Backboard API — waiting for AI evaluation...\n'); + + // ── Single call: questions + answers in, grades out ─────────── + const { assistantId, playerResults, grades } = await runMatch(CONFIG.MATCH_ID, PLAYERS); + + console.log(` Assistant: ${assistantId}`); + + // ── AI Verdicts ─────────────────────────────────────────────── + divider('AI VERDICTS (TC / SC / Context)'); + + for (const [name, results] of Object.entries(playerResults)) { + console.log(`\n ${name}:`); + results.forEach((v, q) => { + const ctx = v.Context === 1 ? 'VALID' : 'INVALID'; + console.log(` Q${q + 1}: TC=${pad(v.TC, 12)} SC=${pad(v.SC, 12)} ${ctx}`); + }); + } + + // ── Grades ──────────────────────────────────────────────────── + divider('SCORES'); + + const playerNames = Object.keys(grades); + console.log(` ${pad('Player', 12)}${pad('Q1', 8)}${pad('Q2', 8)}${pad('Q3', 8)}TOTAL`); + console.log(' ' + '─'.repeat(44)); + + playerNames.forEach((name) => { + const scores = grades[name]; + const total = scores.reduce((a, b) => a + b, 0); + console.log(` ${pad(name, 12)}${scores.map((s) => pad(s, 8)).join('')}${total}`); + }); + + // ── Ranking ─────────────────────────────────────────────────── + divider('RANKING'); + + const ranked = playerNames + .map((name) => ({ name, total: grades[name].reduce((a, b) => a + b, 0) })) + .sort((a, b) => b.total - a.total); + + ranked.forEach((r, i) => { + console.log(` #${i + 1} ${pad(r.name, 12)} ${r.total} pts`); + }); + + divider(); +} + +main().catch((err) => { + console.error('MATCH FAILED:', err); + process.exit(1); +}); diff --git a/backbaord/test/package.json b/backbaord/test/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/backbaord/test/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/components/arena/code-editor.tsx b/components/arena/code-editor.tsx new file mode 100644 index 0000000..b7fbb1f --- /dev/null +++ b/components/arena/code-editor.tsx @@ -0,0 +1,986 @@ +"use client" + +import { useEffect, useMemo, useRef, useState } from "react" +import { Play, RotateCcw, ChevronDown } from "lucide-react" +import dynamic from "next/dynamic" +import { getAuthHeaders } from "@/lib/client-auth" + +const MonacoEditor = dynamic( + () => import("@monaco-editor/react").then((mod) => mod.default), + { ssr: false }, +) + +const PISTON_BASE = "https://emkc.org/api/v2/piston" + +interface PistonRuntime { + language: string + version: string + aliases: string[] + runtime?: string +} + +interface PistonStageResult { + stdout: string + stderr: string + output: string + code: number | null + signal: string | null +} + +interface PistonExecuteResponse { + language: string + version: string + run: PistonStageResult + compile?: PistonStageResult +} + +const jsonGuard = async (res: Response): Promise => { + const contentType = res.headers.get("content-type") ?? "" + const text = await res.text() + + if (!res.ok) { + throw new Error( + `Piston returned ${res.status}. ${text.slice(0, 160) || "Empty body"}`, + ) + } + + if (!contentType.toLowerCase().includes("application/json")) { + throw new Error( + `Piston responded with non-JSON (content-type: ${contentType || "unknown"}). Body starts with: ${text + .replace(/\s+/g, " ") + .slice(0, 160)}`, + ) + } + + try { + return JSON.parse(text) as T + } catch (err) { + throw new Error( + `Failed to parse Piston JSON: ${(err as Error).message}. Body starts with: ${text + .replace(/\s+/g, " ") + .slice(0, 160)}`, + ) + } +} + +const baseHeaders = { + Accept: "application/json", +} + +const defaultCode = `def two_sum(nums, target): + """ + Given an array of integers nums and + an integer target, return indices of + the two numbers that add up to target. + """ + seen = {} + for i, num in enumerate(nums): + complement = target - num + if complement in seen: + return [seen[complement], i] + seen[num] = i + return [] + +# Test +print(two_sum([2, 7, 11, 15], 9)) +` + +const starterSnippets: Record = { + python: defaultCode, + javascript: 'console.log("Hello, Piston!");', + java: `public class Main { + public static void main(String[] args) { + System.out.println("Hello, Piston!"); + } +}`, + "c++": `#include +using namespace std; + +int main() { + cout << "Hello, Piston!" << "\\n"; + return 0; +}`, +} + +const normalizeLanguage = (language: string) => language.trim().toLowerCase() + +const snippetForLanguage = (language: string) => { + const key = normalizeLanguage(language) + if (key.includes("python")) return starterSnippets.python + if (key.includes("javascript") || key === "js" || key.includes("node")) { + return starterSnippets.javascript + } + if (key === "java") return starterSnippets.java + if (key.includes("c++") || key.includes("cpp")) return starterSnippets["c++"] + return undefined +} + +const fileNameForLanguage = (language: string) => { + const key = normalizeLanguage(language) + if (key.includes("python")) return "main.py" + if (key.includes("javascript") || key === "js" || key.includes("node")) { + return "main.js" + } + if (key === "java") return "Main.java" + if (key.includes("c++") || key.includes("cpp")) return "main.cpp" + return undefined +} + +const matchesLanguage = (runtime: PistonRuntime, selection: string) => { + const target = normalizeLanguage(selection) + const runtimeName = normalizeLanguage(runtime.language) + const aliases = runtime.aliases?.map(normalizeLanguage) ?? [] + + if (target === "c++") { + return ( + runtimeName.includes("c++") || + runtimeName.includes("cpp") || + aliases.includes("c++") || + aliases.includes("cpp") + ) + } + if (target === "javascript") { + return ( + runtimeName.includes("javascript") || + runtimeName.includes("node") || + aliases.includes("javascript") || + aliases.includes("node") + ) + } + return runtimeName === target || aliases.includes(target) +} + +const outputFromResult = (result: PistonExecuteResponse) => { + const compileOutput = result.compile?.output?.trim() + if (compileOutput) return compileOutput + + const runOutput = result.run.output?.trim() + if (runOutput) return runOutput + + if (result.run.stderr) return result.run.stderr + if (result.run.stdout) return result.run.stdout + return "No output produced." +} + +type CodeEditorProblem = { + title: string + difficulty?: string + tag?: string + description: string + examples?: { input: string; output: string }[] + hiddenTests?: { input: string; output: string }[] + constraints?: string[] +} + +type CodeEditorProps = { + problem?: CodeEditorProblem + matchId?: string | null + questionId?: string | null + problemTitle?: string + onSubmissionRecorded?: (payload: { + questionId: string + testsPassed: number + testsTotal: number + }) => void + onCodeChange?: (code: string) => void + onLanguageChange?: (language: string) => void +} + +type SubmissionCacheItem = { + problemTitle: string + codeDescription: string + submittedAt: string + testsPassed: string +} + +export function CodeEditor({ + problem, + matchId, + questionId, + problemTitle, + onSubmissionRecorded, + onCodeChange, + onLanguageChange, +}: CodeEditorProps) { + const [code, setCode] = useState(defaultCode) + const [activeTab, setActiveTab] = useState<"problem" | "input">("problem") + const [language, setLanguage] = useState("python") + const [showLangDropdown, setShowLangDropdown] = useState(false) + const [output, setOutput] = useState("") + const [customInput, setCustomInput] = useState("") + const [runtimes, setRuntimes] = useState([]) + const [runtimeError, setRuntimeError] = useState(null) + const [isLoadingRuntimes, setIsLoadingRuntimes] = useState(false) + const [isRunning, setIsRunning] = useState(false) + const [isRunningSamples, setIsRunningSamples] = useState(false) + const [sampleError, setSampleError] = useState(null) + const [sampleResults, setSampleResults] = useState< + Array<{ + input: string + expected: string + actual?: string + status: "pass" | "fail" | "error" | "idle" + }> + >([]) + const [isRunningHidden, setIsRunningHidden] = useState(false) + const [hiddenError, setHiddenError] = useState(null) + const [hiddenResults, setHiddenResults] = useState< + Array<{ + status: "pass" | "fail" | "error" | "idle" + }> + >([]) + const [submissionCache, setSubmissionCache] = useState( + [], + ) + const activeProblem = problem + const problemTag = activeProblem?.tag + const problemDifficulty = activeProblem?.difficulty + const activeTitle = activeProblem?.title ?? "Problem" + const activeDescription = + activeProblem?.description ?? "Loading problem..." + const activeExamples = activeProblem?.examples ?? [] + const activeHiddenTests = activeProblem?.hiddenTests ?? [] + const activeConstraints = activeProblem?.constraints ?? [] + const difficultyClass = (() => { + const normalized = problemDifficulty?.toLowerCase() + if (normalized === "easy") { + return "border-emerald-500/40 bg-emerald-500/10 text-emerald-300" + } + if (normalized === "medium") { + return "border-amber-500/40 bg-amber-500/10 text-amber-300" + } + if (normalized === "hard") { + return "border-rose-500/40 bg-rose-500/10 text-rose-300" + } + return "border-primary/30 bg-primary/10 text-primary" + })() + const saveTimerRef = useRef(null) + const lastTitleRef = useRef(null) + + const storageTitle = problemTitle ?? activeProblem?.title ?? "" + + useEffect(() => { + if (!storageTitle) return + if (lastTitleRef.current !== storageTitle) { + const isFirstLoad = lastTitleRef.current === null + lastTitleRef.current = storageTitle + if (typeof window !== "undefined") { + const cached = window.localStorage.getItem(storageTitle) + if (cached !== null) { + setCode(cached) + } else if (!isFirstLoad) { + setCode("") + } + } + } + }, [storageTitle]) + useEffect(() => { + if (onCodeChange) { + onCodeChange(code) + } + }, [code, onCodeChange]) + + useEffect(() => { + if (onLanguageChange) { + onLanguageChange(language) + } + }, [language, onLanguageChange]) + + useEffect(() => { + if (!storageTitle) return + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current) + } + saveTimerRef.current = window.setTimeout(() => { + if (typeof window === "undefined") return + window.localStorage.setItem(storageTitle, code) + }, 3000) + return () => { + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current) + } + } + }, [code, storageTitle]) + + const handleEditorWillMount = (monaco: any) => { + monaco.editor.defineTheme("black-green", { + base: "vs-dark", + inherit: false, + semanticHighlighting: false, + rules: [ + { token: "", foreground: "FFFFFF", background: "000000" }, + { token: "comment", foreground: "FFFFFF" }, + { token: "string", foreground: "FFFFFF" }, + { token: "number", foreground: "FFFFFF" }, + { token: "keyword", foreground: "FFFFFF" }, + { token: "identifier", foreground: "FFFFFF" }, + { token: "delimiter", foreground: "FFFFFF" }, + { token: "type", foreground: "FFFFFF" }, + { token: "function", foreground: "FFFFFF" }, + ], + colors: { + "editor.background": "#000000", + "editor.foreground": "#FFFFFF", + "editorLineNumber.foreground": "#5AAA5A", + "editorCursor.foreground": "#FFFFFF", + "editor.selectionBackground": "#0B2A0B", + "editor.inactiveSelectionBackground": "#082008", + "editor.lineHighlightBackground": "#081B08", + "editorIndentGuide.background": "#123312", + "editorIndentGuide.activeBackground": "#39FF14", + }, + }) + } + + const selectedRuntime = useMemo(() => { + return runtimes.find((runtime) => matchesLanguage(runtime, language)) ?? null + }, [language, runtimes]) + + useEffect(() => { + let isActive = true + + const loadRuntimes = async () => { + setIsLoadingRuntimes(true) + setRuntimeError(null) + try { + const response = await fetch(`${PISTON_BASE}/runtimes`, { + cache: "no-store", + headers: baseHeaders, + }) + const data = await jsonGuard(response) + if (!isActive) return + setRuntimes(data) + } catch (err: unknown) { + if (!isActive) return + const message = err instanceof Error ? err.message : "Failed to load runtimes." + setRuntimeError(message) + setRuntimes([]) + } finally { + if (isActive) setIsLoadingRuntimes(false) + } + } + + loadRuntimes() + + return () => { + isActive = false + } + }, []) + + const handleRun = async () => { + setActiveTab("input") + setOutput("Running...\n") + setIsRunning(true) + + if (!selectedRuntime) { + setOutput("No runtime available for the selected language.") + setIsRunning(false) + return + } + + try { + const fileName = fileNameForLanguage(language) + const submissionRes = await fetch(`${PISTON_BASE}/execute`, { + method: "POST", + headers: { + ...baseHeaders, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + language: selectedRuntime.language, + version: selectedRuntime.version, + files: [ + { + ...(fileName ? { name: fileName } : {}), + content: code, + }, + ], + stdin: customInput, + }), + }) + + const execution = await jsonGuard(submissionRes) + setOutput(outputFromResult(execution)) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to run code." + setOutput(`Error: ${message}`) + } finally { + setIsRunning(false) + } + } + + const handleRunSamples = async () => { + setSampleError(null) + const examples = activeExamples + + if (!examples || examples.length === 0) { + setSampleError("No sample tests available for this question.") + return + } + + if (!selectedRuntime) { + setSampleError("No runtime available for the selected language.") + return + } + + setIsRunningSamples(true) + setSampleResults( + examples.map((ex) => ({ + input: ex.input, + expected: ex.output, + status: "idle", + })), + ) + + const fileName = fileNameForLanguage(language) + const results: Array<{ + input: string + expected: string + actual?: string + status: "pass" | "fail" | "error" | "idle" + }> = [] + + for (const ex of examples) { + try { + const submissionRes = await fetch(`${PISTON_BASE}/execute`, { + method: "POST", + headers: { + ...baseHeaders, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + language: selectedRuntime.language, + version: selectedRuntime.version, + files: [ + { + ...(fileName ? { name: fileName } : {}), + content: code, + }, + ], + stdin: ex.input ?? "", + }), + }) + + const execution = await jsonGuard(submissionRes) + const actual = (execution.run.stdout || execution.run.output || "") + .trim() + const expected = (ex.output ?? "").trim() + const status = actual === expected ? "pass" : "fail" + results.push({ + input: ex.input, + expected: ex.output, + actual, + status, + }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Execution failed." + results.push({ + input: ex.input, + expected: ex.output, + actual: message, + status: "error", + }) + } + setSampleResults([...results]) + } + + setIsRunningSamples(false) + } + + const handleRunHidden = async () => { + setHiddenError(null) + const hiddenTests = activeHiddenTests + if (!hiddenTests || hiddenTests.length === 0) { + setHiddenError("No hidden tests available for this question.") + return + } + + if (!selectedRuntime) { + setHiddenError("No runtime available for the selected language.") + return + } + + setIsRunningHidden(true) + setHiddenResults( + hiddenTests.map(() => ({ + status: "idle", + })), + ) + + const fileName = fileNameForLanguage(language) + const results: Array<{ + status: "pass" | "fail" | "error" | "idle" + }> = [] + + for (const test of hiddenTests) { + try { + const submissionRes = await fetch(`${PISTON_BASE}/execute`, { + method: "POST", + headers: { + ...baseHeaders, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + language: selectedRuntime.language, + version: selectedRuntime.version, + files: [ + { + ...(fileName ? { name: fileName } : {}), + content: code, + }, + ], + stdin: test.input ?? "", + }), + }) + + const execution = await jsonGuard(submissionRes) + const actual = (execution.run.stdout || execution.run.output || "").trim() + const expected = (test.output ?? "").trim() + const status = actual === expected ? "pass" : "fail" + results.push({ + status, + }) + } catch { + results.push({ + status: "error", + }) + } + setHiddenResults([...results]) + } + + setIsRunningHidden(false) + } + + const handleSubmit = async () => { + setActiveTab("input") + setOutput("Submitting...\n") + + if (!matchId || !questionId) { + setOutput("Missing match or question context. Unable to submit.") + return + } + + try { + const res = await fetch(`/api/match/${matchId}/submit`, { + method: "POST", + headers: { "Content-Type": "application/json", ...getAuthHeaders() }, + body: JSON.stringify({ + questionId, + code, + language, + }), + }) + const payload = await res.json().catch(() => ({})) + if (!res.ok) { + setOutput(payload?.error || "Submission failed.") + return + } + + const testsPassed = payload?.submission?.testsPassed ?? 0 + const testsTotal = payload?.submission?.testsTotal ?? 0 + const outputText = + payload?.submission?.stdout || + payload?.submission?.stderr || + "Submitted." + + setOutput( + `${outputText}\n\nTests: ${testsPassed}/${testsTotal}\nSubmission recorded.`, + ) + + const cacheItem: SubmissionCacheItem = { + problemTitle: problemTitle ?? activeTitle, + codeDescription: code, + submittedAt: new Date().toISOString(), + testsPassed: `${testsPassed}/${testsTotal}`, + } + + setSubmissionCache((prev) => { + const next = [cacheItem, ...prev] + if (typeof window !== "undefined") { + window.localStorage.setItem( + "cnc.submissions", + JSON.stringify(next), + ) + } + return next + }) + + if (onSubmissionRecorded && questionId) { + onSubmissionRecorded({ questionId, testsPassed, testsTotal }) + } + } catch (err) { + setOutput(err instanceof Error ? err.message : "Submission failed.") + } + } + + return ( +
+ {/* Left panel: Problem + Output */} +
+ {/* Tabs */} +
+ + +
+ + {/* Content */} +
+ {activeTab === "problem" ? ( +
+
+ {problemTag} +

+ {activeTitle} +

+ {problemDifficulty && ( + + {problemDifficulty} + + )} +
+

+ {activeDescription} +

+
+ + {"Examples"} + + {activeExamples.map((ex, i) => ( +
+
Input
+
+                      {ex.input}
+                    
+
Output
+
+                      {ex.output}
+                    
+
+ ))} +
+ {activeConstraints.length > 0 && ( +
+ + {"Constraints"} + +
    + {activeConstraints.map((c) => ( +
  • + {">"} + {c} +
  • + ))} +
+
+ )} + +
+
+ + {"Sample tests"} + + +
+ + {sampleError && ( +
+ {sampleError} +
+ )} + +
+ {(sampleResults.length > 0 + ? sampleResults + : activeExamples.map((ex) => ({ + input: ex.input, + expected: ex.output, + status: "idle" as const, + })) + ).map((result, index) => ( +
+
+ + {"Case " + (index + 1)} + + + {result.status === "pass" + ? "PASS" + : result.status === "fail" + ? "FAIL" + : result.status === "error" + ? "ERROR" + : "NOT RUN"} + +
+
Input
+
+                        {result.input}
+                      
+
Expected
+
+                        {result.expected}
+                      
+ {result.status !== "idle" && ( + <> +
Actual
+
+                            {result.actual ?? ""}
+                          
+ + )} +
+ ))} +
+ +
+ + {"Hidden tests"} + + +
+ + {hiddenError && ( +
+ {hiddenError} +
+ )} + +
+ {(hiddenResults.length > 0 + ? hiddenResults + : activeHiddenTests.map(() => ({ + status: "idle" as const, + })) + ).map((result, index) => ( +
+
+ + {"Hidden Case " + (index + 1)} + + + {result.status === "pass" + ? "PASS" + : result.status === "fail" + ? "FAIL" + : result.status === "error" + ? "ERROR" + : "NOT RUN"} + +
+
Input
+
+                        Hidden
+                      
+
+ ))} + {!activeHiddenTests.length && ( +
+ No hidden tests configured. +
+ )} +
+
+
+ ) : ( +
+
+ + {"Custom input"} + + + {"stdin"} + +
+