From 778a41d1b17bd71b62cf6c66f7e9dc5f5ea88966 Mon Sep 17 00:00:00 2001 From: KeshavAnandCode Date: Wed, 28 Jan 2026 18:57:52 -0600 Subject: [PATCH] somehwat AI CSS...but not good at all in teh slightest, logic works --- src/app/api/game/[code]/route.ts | 97 +++++ src/app/api/room/[code]/route.ts | 113 ++++-- src/app/api/room/create/route.ts | 16 +- src/app/api/room/join/route.ts | 27 +- src/app/globals.css | 624 +++++++++++++++++++++++++++++++ src/app/host/page.tsx | 468 +++++++++++++++++++++-- src/app/layout.tsx | 8 +- src/app/play/page.tsx | 531 ++++++++++++++++++++++++-- src/components/Timer.tsx | 91 +++++ src/lib/rooms.ts | 247 +++++++++++- src/types/index.ts | 22 ++ 11 files changed, 2104 insertions(+), 140 deletions(-) create mode 100644 src/app/api/game/[code]/route.ts create mode 100644 src/components/Timer.tsx diff --git a/src/app/api/game/[code]/route.ts b/src/app/api/game/[code]/route.ts new file mode 100644 index 0000000..a7405fa --- /dev/null +++ b/src/app/api/game/[code]/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from 'next/server'; +import { getRoom, buzz, startGame, loadQuestion, startTossupTimer, startBonusTimer, markTossupCorrect, markTossupWrong, markBonusCorrect, markBonusWrong, endGame } from '@/lib/rooms'; +import { getRandomQuestion } from '@/lib/scibowl-api'; +import { z, ZodError } from 'zod'; +const gameActionSchema = z.object({ + action: z.enum(['start', 'load_question', 'start_timer', 'buzz', 'tossup_correct', 'tossup_wrong', 'bonus_start_timer', 'bonus_correct', 'bonus_wrong', 'end']), + moderatorId: z.string().optional(), + playerId: z.string().optional(), +}); + +export async function POST( + req: Request, + { params }: { params: Promise<{ code: string }> } +) { + try { + const { code } = await params; + const body = await req.json(); + const { action, moderatorId, playerId } = gameActionSchema.parse(body); + + let success = false; + + switch (action) { + case 'start': + if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 }); + success = startGame(code, moderatorId); + break; + + case 'load_question': + if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 }); + const question = await getRandomQuestion(); + success = loadQuestion(code, moderatorId, question); + break; + + case 'start_timer': + if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 }); + success = startTossupTimer(code, moderatorId); + break; + + case 'buzz': + if (!playerId) return NextResponse.json({ error: 'Missing playerId' }, { status: 400 }); + success = buzz(code, playerId); + break; + + case 'tossup_correct': + if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 }); + success = markTossupCorrect(code, moderatorId); + break; + + case 'tossup_wrong': + if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 }); + success = markTossupWrong(code, moderatorId); + break; + + case 'bonus_start_timer': + if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 }); + success = startBonusTimer(code, moderatorId); + break; + + case 'bonus_correct': + if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 }); + success = markBonusCorrect(code, moderatorId); + break; + + case 'bonus_wrong': + if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 }); + success = markBonusWrong(code, moderatorId); + break; + + case 'end': + if (!moderatorId) return NextResponse.json({ error: 'Missing moderatorId' }, { status: 400 }); + success = endGame(code, moderatorId); + break; + } + + if (!success) { + return NextResponse.json({ error: 'Action failed' }, { status: 400 }); + } + + const room = getRoom(code); + return NextResponse.json({ + success: true, + gameState: room?.gameState + }); + + } catch (error: unknown) { + console.error('Game action error:', error); + + if (error instanceof ZodError) { + return NextResponse.json( + { error: error.errors[0].message }, + { status: 400 } + ); + } + + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } +} \ No newline at end of file diff --git a/src/app/api/room/[code]/route.ts b/src/app/api/room/[code]/route.ts index 8c6cccf..e677801 100644 --- a/src/app/api/room/[code]/route.ts +++ b/src/app/api/room/[code]/route.ts @@ -1,41 +1,102 @@ import { NextResponse } from 'next/server'; import { getRoom, closeRoom, leaveRoom } from '@/lib/rooms'; +import { rateLimit, getClientIdentifier } from '@/lib/rate-limit'; +import { roomCodeSchema, roomActionSchema } from '@/lib/validation'; +import { ZodError } from 'zod'; export async function GET( req: Request, { params }: { params: Promise<{ code: string }> } ) { - const { code } = await params; - const room = getRoom(code); - - if (!room) { - return NextResponse.json({ error: 'Room not found' }, { status: 404 }); + try { + const clientId = getClientIdentifier(req); + const rateLimitResult = rateLimit(`get:${clientId}`, 500, 60000); // 200 requests per minute + if (!rateLimitResult.success) { + return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); + } + + const { code } = await params; + const validatedCode = roomCodeSchema.parse(code); + + const room = getRoom(validatedCode); + + if (!room) { + return NextResponse.json({ error: 'Room not found' }, { status: 404 }); + } + + return NextResponse.json({ + code: room.code, + playerCount: room.players.size, + players: Array.from(room.players.values()).map(p => ({ + id: p.id, + name: p.name, + score: p.score, + teamId: p.teamId, + })), + teams: Array.from(room.teams.values()), + gameState: room.gameState, + isActive: room.isActive, + }); + } catch (error: unknown) { + console.error('Get room error:', error); + + if (error instanceof ZodError) { + return NextResponse.json({ error: 'Invalid room code' }, { status: 400 }); + } + + return NextResponse.json({ error: 'Failed to get room' }, { status: 400 }); } - - return NextResponse.json({ - code: room.code, - playerCount: room.players.size, - players: Array.from(room.players.values()), - isActive: room.isActive, - }); } export async function POST( req: Request, { params }: { params: Promise<{ code: string }> } ) { - const { code } = await params; - const body = await req.json(); - - if (body.action === 'close') { - const success = closeRoom(code, body.moderatorId); - return NextResponse.json({ success }); + try { + const clientId = getClientIdentifier(req); + const rateLimitResult = rateLimit(`action:${clientId}`, 30, 60000); + + if (!rateLimitResult.success) { + return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); + } + + const { code } = await params; + const validatedCode = roomCodeSchema.parse(code); + + const body = await req.json(); + const validatedAction = roomActionSchema.parse(body); + + if (validatedAction.action === 'close') { + if (!validatedAction.moderatorId) { + return NextResponse.json({ error: 'Moderator ID required' }, { status: 400 }); + } + + const success = closeRoom(validatedCode, validatedAction.moderatorId); + + if (!success) { + return NextResponse.json({ error: 'Unauthorized or room not found' }, { status: 403 }); + } + + return NextResponse.json({ success: true }); + } + + if (validatedAction.action === 'leave') { + if (!validatedAction.playerId) { + return NextResponse.json({ error: 'Player ID required' }, { status: 400 }); + } + + const success = leaveRoom(validatedCode, validatedAction.playerId); + return NextResponse.json({ success }); + } + + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } catch (error: unknown) { + console.error('Room action error:', error); + + if (error instanceof ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }); + } + + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); } - - if (body.action === 'leave') { - const success = leaveRoom(code, body.playerId); - return NextResponse.json({ success }); - } - - return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); -} \ No newline at end of file +} diff --git a/src/app/api/room/create/route.ts b/src/app/api/room/create/route.ts index 8b0b4bd..9422145 100644 --- a/src/app/api/room/create/route.ts +++ b/src/app/api/room/create/route.ts @@ -1,11 +1,14 @@ import { NextResponse } from 'next/server'; import { createRoom } from '@/lib/rooms'; import { rateLimit, getClientIdentifier } from '@/lib/rate-limit'; -import { createRoomSchema } from '@/lib/validation'; +import { z } from 'zod'; + +const createRoomSchema = z.object({ + teamMode: z.enum(['1', '2']).optional().default('2'), +}); export async function POST(req: Request) { try { - // Rate limit: max 5 rooms per IP per hour const clientId = getClientIdentifier(req); const rateLimitResult = rateLimit(`create:${clientId}`, 5, 3600000); @@ -16,18 +19,17 @@ export async function POST(req: Request) { ); } - // Validate request (empty body is fine for create) const body = await req.json().catch(() => ({})); - createRoomSchema.parse(body); - - const { code, moderatorId } = createRoom(); + const { teamMode } = createRoomSchema.parse(body); + + const { code, moderatorId } = createRoom(parseInt(teamMode) as 1 | 2); return NextResponse.json({ code, moderatorId, remaining: rateLimitResult.remaining }); - } catch (error: unknown) { + } catch (error) { console.error('Create room error:', error); return NextResponse.json( { error: 'Failed to create room' }, diff --git a/src/app/api/room/join/route.ts b/src/app/api/room/join/route.ts index 0d2b270..17c1326 100644 --- a/src/app/api/room/join/route.ts +++ b/src/app/api/room/join/route.ts @@ -1,11 +1,16 @@ import { NextResponse } from 'next/server'; import { joinRoom } from '@/lib/rooms'; import { rateLimit, getClientIdentifier } from '@/lib/rate-limit'; -import { joinRoomSchema } from '@/lib/validation'; +import { z } from 'zod'; + +const joinRoomSchema = z.object({ + code: z.string().length(6).regex(/^[A-Z0-9]+$/), + playerName: z.string().min(1).max(20).regex(/^[a-zA-Z0-9\s]+$/).transform(val => val.trim()), + teamId: z.enum(['1', '2']), +}); export async function POST(req: Request) { try { - // Rate limit: max 20 join attempts per IP per minute const clientId = getClientIdentifier(req); const rateLimitResult = rateLimit(`join:${clientId}`, 20, 60000); @@ -19,26 +24,22 @@ export async function POST(req: Request) { const body = await req.json(); const validatedData = joinRoomSchema.parse(body); - const result = joinRoom(validatedData.code, validatedData.playerName); + const result = joinRoom( + validatedData.code, + validatedData.playerName, + parseInt(validatedData.teamId) as 1 | 2 + ); if (!result) { return NextResponse.json( - { error: 'Room not found or inactive' }, + { error: 'Room not found, inactive, or game in progress' }, { status: 404 } ); } return NextResponse.json(result); - } catch (error: any) { + } catch (error) { console.error('Join room error:', error); - - if (error.name === 'ZodError') { - return NextResponse.json( - { error: error.errors[0].message }, - { status: 400 } - ); - } - return NextResponse.json( { error: 'Invalid request' }, { status: 400 } diff --git a/src/app/globals.css b/src/app/globals.css index b5c61c9..e3f3b88 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,3 +1,627 @@ @tailwind base; @tailwind components; @tailwind utilities; + +:root { + /* Catppuccin Mocha */ + --ctp-rosewater: #f5e0dc; + --ctp-flamingo: #f2cdcd; + --ctp-pink: #f5c2e7; + --ctp-mauve: #cba6f7; + --ctp-red: #f38ba8; + --ctp-maroon: #eba0ac; + --ctp-peach: #fab387; + --ctp-yellow: #f9e2af; + --ctp-green: #a6e3a1; + --ctp-teal: #94e2d5; + --ctp-sky: #89dceb; + --ctp-sapphire: #74c7ec; + --ctp-blue: #89b4fa; + --ctp-lavender: #b4befe; + --ctp-text: #cdd6f4; + --ctp-subtext1: #bac2de; + --ctp-subtext0: #a6adc8; + --ctp-overlay2: #9399b2; + --ctp-overlay1: #7f849c; + --ctp-overlay0: #6c7086; + --ctp-surface2: #585b70; + --ctp-surface1: #45475a; + --ctp-surface0: #313244; + --ctp-base: #1e1e2e; + --ctp-mantle: #181825; + --ctp-crust: #11111b; +} + +* { + box-sizing: border-box; +} + +body { + background: linear-gradient( + 135deg, + var(--ctp-base) 0%, + var(--ctp-mantle) 50%, + var(--ctp-crust) 100% + ); + background-attachment: fixed; + color: var(--ctp-text); + min-height: 100vh; +} + +/* Gradient overlays */ +.gradient-overlay { + position: relative; + overflow: hidden; +} + +.gradient-overlay::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 135deg, + rgba(203, 166, 247, 0.1) 0%, + rgba(137, 180, 250, 0.1) 100% + ); + pointer-events: none; + animation: gradient-shift 8s ease infinite; +} + +@keyframes gradient-shift { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 0.8; + } +} + +/* Card styles */ +.card { + background: var(--ctp-surface0); + border: 2px solid var(--ctp-surface2); + border-radius: 1rem; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; +} + +.card::before { + content: ""; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.1), + transparent + ); + transition: left 0.5s; +} + +.card:hover::before { + left: 100%; +} + +.card:hover { + border-color: var(--ctp-mauve); + transform: translateY(-2px); + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4); +} + +/* Button styles */ +.btn { + position: relative; + overflow: hidden; + transition: all 0.3s ease; + font-weight: 700; + border-radius: 0.75rem; + cursor: pointer; + border: none; +} + +.btn::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + transform: translate(-50%, -50%); + transition: + width 0.6s, + height 0.6s; +} + +.btn:active::before { + width: 300px; + height: 300px; +} + +.btn:hover { + transform: translateY(-2px); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); +} + +.btn:active { + transform: translateY(0); +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideInLeft { + from { + transform: translateX(-100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes scaleIn { + from { + transform: scale(0.8); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +@keyframes pulse-glow { + 0%, + 100% { + box-shadow: + 0 0 20px var(--ctp-red), + 0 0 40px var(--ctp-red), + 0 0 60px var(--ctp-red); + } + 50% { + box-shadow: + 0 0 30px var(--ctp-red), + 0 0 60px var(--ctp-red), + 0 0 90px var(--ctp-red); + } +} + +@keyframes pulse-scale { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } +} + +@keyframes timer-pulse { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } +} + +@keyframes float { + 0%, + 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +} + +@keyframes shimmer { + 0% { + background-position: -1000px 0; + } + 100% { + background-position: 1000px 0; + } +} + +@keyframes spin-slow { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Utility classes */ +.fade-in { + animation: fadeIn 0.5s ease-out; +} + +.slide-in-left { + animation: slideInLeft 0.5s ease-out; +} + +.slide-in-right { + animation: slideInRight 0.5s ease-out; +} + +.scale-in { + animation: scaleIn 0.4s ease-out; +} + +.float { + animation: float 3s ease-in-out infinite; +} + +/* Buzz button */ +.buzz-button { + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + background: linear-gradient( + 135deg, + var(--ctp-red) 0%, + var(--ctp-maroon) 100% + ); + box-shadow: 0 10px 40px rgba(243, 139, 168, 0.5); +} + +.buzz-button::after { + content: ""; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: linear-gradient( + 45deg, + transparent 30%, + rgba(255, 255, 255, 0.3) 50%, + transparent 70% + ); + transform: rotate(45deg); + animation: shimmer 3s infinite; +} + +.buzz-button:hover { + transform: scale(1.1); + box-shadow: 0 15px 50px rgba(243, 139, 168, 0.7); +} + +.buzz-button:active { + transform: scale(0.95); +} + +.buzz-active { + animation: + pulse-glow 1s infinite, + pulse-scale 1s infinite; +} + +.buzz-disabled { + opacity: 0.5; + cursor: not-allowed; + filter: grayscale(1); +} + +/* Timer */ +.timer-warning { + animation: timer-pulse 0.5s ease-in-out infinite; +} + +.timer-bar { + transition: + width 0.3s ease-out, + background-color 0.3s ease; + border-radius: 9999px; + position: relative; + overflow: hidden; +} + +.timer-bar::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.3), + transparent + ); + animation: shimmer 2s infinite; +} + +/* Score displays */ +.score-card { + background: linear-gradient( + 135deg, + var(--ctp-surface0) 0%, + var(--ctp-surface1) 100% + ); + border: 2px solid; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.score-card::before { + content: ""; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient( + circle, + rgba(255, 255, 255, 0.1) 0%, + transparent 70% + ); + animation: spin-slow 20s linear infinite; +} + +.team1-card { + border-color: var(--ctp-blue); + box-shadow: 0 0 30px rgba(137, 180, 250, 0.2); +} + +.team2-card { + border-color: var(--ctp-red); + box-shadow: 0 0 30px rgba(243, 139, 168, 0.2); +} + +.individual-card { + border-color: var(--ctp-mauve); + box-shadow: 0 0 30px rgba(203, 166, 247, 0.2); +} + +/* Question cards */ +.question-card { + background: linear-gradient( + 135deg, + var(--ctp-surface0) 0%, + var(--ctp-surface1) 100% + ); + border: 2px solid var(--ctp-surface2); + border-radius: 1.5rem; + padding: 2rem; + position: relative; + overflow: hidden; +} + +.question-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient( + 90deg, + var(--ctp-red), + var(--ctp-yellow), + var(--ctp-green), + var(--ctp-blue), + var(--ctp-mauve) + ); + background-size: 200% 100%; + animation: shimmer 3s linear infinite; +} + +/* Glow effects */ +.glow-green { + box-shadow: 0 0 20px rgba(166, 227, 161, 0.5); +} + +.glow-red { + box-shadow: 0 0 20px rgba(243, 139, 168, 0.5); +} + +.glow-blue { + box-shadow: 0 0 20px rgba(137, 180, 250, 0.5); +} + +.glow-yellow { + box-shadow: 0 0 20px rgba(249, 226, 175, 0.5); +} + +.glow-mauve { + box-shadow: 0 0 20px rgba(203, 166, 247, 0.5); +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 14px; +} + +::-webkit-scrollbar-track { + background: var(--ctp-mantle); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb { + background: linear-gradient( + 180deg, + var(--ctp-surface1) 0%, + var(--ctp-surface2) 100% + ); + border-radius: 10px; + border: 2px solid var(--ctp-mantle); +} + +::-webkit-scrollbar-thumb:hover { + background: linear-gradient( + 180deg, + var(--ctp-surface2) 0%, + var(--ctp-overlay0) 100% + ); +} + +/* Text gradients */ +.text-gradient-mauve { + background: linear-gradient( + 135deg, + var(--ctp-mauve) 0%, + var(--ctp-pink) 100% + ); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.text-gradient-blue { + background: linear-gradient( + 135deg, + var(--ctp-blue) 0%, + var(--ctp-sapphire) 100% + ); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.text-gradient-green { + background: linear-gradient( + 135deg, + var(--ctp-green) 0%, + var(--ctp-teal) 100% + ); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* Loading states */ +.skeleton { + background: linear-gradient( + 90deg, + var(--ctp-surface0) 25%, + var(--ctp-surface1) 50%, + var(--ctp-surface0) 75% + ); + background-size: 200% 100%; + animation: shimmer 2s infinite; + border-radius: 0.5rem; +} + +/* Badges */ +.badge { + display: inline-block; + padding: 0.5rem 1rem; + border-radius: 9999px; + font-weight: 700; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +/* Status indicators */ +.status-dot { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; + margin-right: 0.5rem; + animation: pulse-scale 2s ease-in-out infinite; +} + +.status-active { + background: var(--ctp-green); + box-shadow: 0 0 10px var(--ctp-green); +} + +.status-waiting { + background: var(--ctp-yellow); + box-shadow: 0 0 10px var(--ctp-yellow); +} + +.status-error { + background: var(--ctp-red); + box-shadow: 0 0 10px var(--ctp-red); +} + +/* Glass morphism */ +.glass { + background: rgba(49, 50, 68, 0.7); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(205, 214, 244, 0.1); +} + +/* Neon text */ +.neon-text { + text-shadow: + 0 0 10px currentColor, + 0 0 20px currentColor, + 0 0 30px currentColor, + 0 0 40px currentColor; +} + +/* Particle background effect */ +@keyframes particle-float { + 0%, + 100% { + transform: translate(0, 0) rotate(0deg); + } + 33% { + transform: translate(30px, -30px) rotate(120deg); + } + 66% { + transform: translate(-20px, 20px) rotate(240deg); + } +} + +.particles { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 0; +} + +.particle { + position: absolute; + width: 4px; + height: 4px; + background: var(--ctp-mauve); + border-radius: 50%; + opacity: 0.3; + animation: particle-float 20s infinite; +} diff --git a/src/app/host/page.tsx b/src/app/host/page.tsx index 429329a..6c71f36 100644 --- a/src/app/host/page.tsx +++ b/src/app/host/page.tsx @@ -1,17 +1,41 @@ "use client"; import { useState, useEffect } from "react"; +import { Player } from "@/types"; +import Cookies from "js-cookie"; +import Timer from "@/components/Timer"; export default function HostPage() { const [roomCode, setRoomCode] = useState(""); const [moderatorId, setModeratorId] = useState(""); - const [players, setPlayers] = useState([]); + const [players, setPlayers] = useState([]); + const [gameState, setGameState] = useState(null); + const [roomClosed, setRoomClosed] = useState(false); + const [teamMode, setTeamMode] = useState<"1" | "2">("2"); + + useEffect(() => { + const savedCode = Cookies.get("host_room_code"); + const savedModId = Cookies.get("host_moderator_id"); + + if (savedCode && savedModId) { + setRoomCode(savedCode); + setModeratorId(savedModId); + } + }, []); async function createRoom() { - const res = await fetch("/api/room/create", { method: "POST" }); + const res = await fetch("/api/room/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ teamMode }), + }); const data = await res.json(); + setRoomCode(data.code); setModeratorId(data.moderatorId); + + Cookies.set("host_room_code", data.code, { expires: 1 / 12 }); + Cookies.set("host_moderator_id", data.moderatorId, { expires: 1 / 12 }); } useEffect(() => { @@ -19,61 +43,441 @@ export default function HostPage() { const interval = setInterval(async () => { const res = await fetch(`/api/room/${roomCode}`); + + if (!res.ok) { + setRoomClosed(true); + Cookies.remove("host_room_code"); + Cookies.remove("host_moderator_id"); + clearInterval(interval); + return; + } + const data = await res.json(); setPlayers(data.players || []); - }, 2000); + setGameState(data.gameState); + }, 1000); return () => clearInterval(interval); }, [roomCode]); + async function gameAction(action: string) { + await fetch(`/api/game/${roomCode}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, moderatorId }), + }); + } + async function closeRoom() { await fetch(`/api/room/${roomCode}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "close", moderatorId }), }); + + Cookies.remove("host_room_code"); + Cookies.remove("host_moderator_id"); window.location.href = "/"; } - if (!roomCode) { + if (roomClosed) { return ( -
- +
+
+

+ Room Closed +

+ + Go Home + +
); } + if (!roomCode) { + return ( +
+
+

+ Create Room +

+ +
+ + + +
+ + +
+
+ ); + } + + const team1 = players.filter((p) => p.teamId === 1); + const team2 = players.filter((p) => p.teamId === 2); + const isIndividual = teamMode === "1"; + + const phase = gameState?.phase || "lobby"; + const question = gameState?.currentQuestion; + const buzzedPlayer = players.find((p) => p.id === gameState?.buzzedPlayer); + return (
-
-

Room Code

-
- {roomCode} +
+ {/* Header */} +
+
+

+ {roomCode} +

+

+ {isIndividual ? "Individual Mode" : "Team Mode"} • Question{" "} + {gameState?.questionNumber || 0} /{" "} + {gameState?.totalQuestions || 20} +

+
+
-
-

- Players ({players.length}) -

- - {players.map((p) => ( -
- {p.name} -
- ))} -
- - +
+

+ {isIndividual ? "Players" : "Team 1"}:{" "} + {team1.reduce((sum, p) => sum + p.score, 0)} +

+
+ {team1.length === 0 ? ( +

+ No players yet... +

+ ) : ( + team1.map((p) => ( +
+ + {p.name} + + + {p.score} + +
+ )) + )} +
+
+ + {!isIndividual && ( +
+

+ Team 2: {team2.reduce((sum, p) => sum + p.score, 0)} +

+
+ {team2.length === 0 ? ( +

+ No players yet... +

+ ) : ( + team2.map((p) => ( +
+ + {p.name} + + + {p.score} + +
+ )) + )} +
+
+ )} +
+ + {/* Game State */} +
+ {phase === "lobby" && ( +
+

+ Waiting for Players... +

+

+ {players.length} player{players.length !== 1 && "s"} joined +

+ +
+ )} + + {phase === "intermission" && ( +
+

+ Ready for Next Question +

+
+ + +
+
+ )} + + {phase === "tossup_reading" && question && ( +
+
+

+ TOSSUP +

+ + {question.category} + +
+ +

+ {question.tossup_question} +

+ +
+ + Answer: + +

+ {question.tossup_answer} +

+
+ + +
+ )} + + {phase === "tossup_buzzing" && question && ( +
+
+

+ BUZZING OPEN +

+ +
+ + {!buzzedPlayer ? ( +

+ Waiting for buzz... +

+ ) : ( +
+
+

+ {buzzedPlayer.name} (Team {buzzedPlayer.teamId}) buzzed! +

+ {gameState?.tossupInterrupted && ( +

+ ⚠️ INTERRUPTED +

+ )} +
+ +
+ + Answer: + +

+ {question.tossup_answer} +

+
+ +
+ + +
+
+ )} +
+ )} + {phase === "bonus" && question && ( +
+
+

+ BONUS - Team {gameState?.currentTeam} +

+ {gameState?.bonusStartTime && ( + + )} +
+ +

+ {question.bonus_question} +

+ +
+ + Answer: + +

+ {question.bonus_answer} +

+
+ + {!gameState?.bonusStartTime ? ( + + ) : ( +
+ + +
+ )} +
+ )} + + {phase === "finished" && ( +
+

+ Game Over! +

+
+ {isIndividual ? ( +
+

+ Final Scores: +

+ {[...team1] + .sort((a, b) => b.score - a.score) + .map((p, i) => ( +
+ {i === 0 && "🏆 "} + {p.name}: {p.score} +
+ ))} +
+ ) : ( +
+
+ + Team 1: {team1.reduce((s, p) => s + p.score, 0)} + + | + + Team 2: {team2.reduce((s, p) => s + p.score, 0)} + +
+

+ {team1.reduce((s, p) => s + p.score, 0) > + team2.reduce((s, p) => s + p.score, 0) + ? "🏆 Team 1 Wins!" + : team2.reduce((s, p) => s + p.score, 0) > + team1.reduce((s, p) => s + p.score, 0) + ? "🏆 Team 2 Wins!" + : "🤝 Tie!"} +

+
+ )} +
+
+ )} +
); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d2399dc..f6f861b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,12 @@ import type { Metadata } from "next"; +import { JetBrains_Mono } from "next/font/google"; import "./globals.css"; +const jetbrainsMono = JetBrains_Mono({ + subsets: ["latin"], + variable: "--font-jetbrains-mono", +}); + export const metadata: Metadata = { title: "SciBowl Quiz", description: "Science Bowl quiz game", @@ -13,7 +19,7 @@ export default function RootLayout({ }) { return ( - {children} + {children} ); } diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 7fbd223..ec3309e 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1,18 +1,102 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; +import Cookies from "js-cookie"; export default function PlayPage() { const [name, setName] = useState(""); const [code, setCode] = useState(""); + const [teamId, setTeamId] = useState<"1" | "2">("1"); const [joined, setJoined] = useState(false); const [playerId, setPlayerId] = useState(""); + const [roomClosed, setRoomClosed] = useState(false); + const [gameState, setGameState] = useState(null); + const [myScore, setMyScore] = useState(0); + const [canBuzz, setCanBuzz] = useState(false); + const [buzzing, setBuzzing] = useState(false); + + useEffect(() => { + const savedCode = Cookies.get("player_room_code"); + const savedPlayerId = Cookies.get("player_id"); + const savedName = Cookies.get("player_name"); + const savedTeamId = Cookies.get("player_team_id"); + + if (savedCode && savedPlayerId && savedName && savedTeamId) { + setCode(savedCode); + setPlayerId(savedPlayerId); + setName(savedName); + setTeamId(savedTeamId as "1" | "2"); + setJoined(true); + } + }, []); + + useEffect(() => { + if (!joined || !code) return; + + const interval = setInterval(async () => { + const res = await fetch(`/api/room/${code}`); + + if (!res.ok) { + setRoomClosed(true); + Cookies.remove("player_room_code"); + Cookies.remove("player_id"); + Cookies.remove("player_name"); + Cookies.remove("player_team_id"); + clearInterval(interval); + return; + } + + const data = await res.json(); + setGameState(data.gameState); + + const me = data.players?.find((p: any) => p.id === playerId); + if (me) setMyScore(me.score); + + const phase = data.gameState?.phase; + setCanBuzz( + (phase === "tossup_reading" || phase === "tossup_buzzing") && + !data.gameState?.buzzedPlayer, + ); + }, 500); + + return () => clearInterval(interval); + }, [joined, code, playerId]); + + const buzz = useCallback(async () => { + if (!canBuzz || buzzing) return; + + setBuzzing(true); + await fetch(`/api/game/${code}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "buzz", playerId }), + }); + + setTimeout(() => setBuzzing(false), 1000); + setCanBuzz(false); + }, [canBuzz, buzzing, code, playerId]); + + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + if (e.code === "Space" && canBuzz && !buzzing) { + e.preventDefault(); + buzz(); + } + }; + + window.addEventListener("keydown", handleKeyPress); + return () => window.removeEventListener("keydown", handleKeyPress); + }, [canBuzz, buzzing, buzz]); async function join() { const res = await fetch("/api/room/join", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code: code.toUpperCase(), playerName: name }), + body: JSON.stringify({ + code: code.toUpperCase(), + playerName: name, + teamId, + }), }); const data = await res.json(); @@ -23,7 +107,13 @@ export default function PlayPage() { } setPlayerId(data.playerId); + setTeamId(data.teamId); setJoined(true); + + Cookies.set("player_room_code", code.toUpperCase(), { expires: 1 / 12 }); + Cookies.set("player_id", data.playerId, { expires: 1 / 12 }); + Cookies.set("player_name", name, { expires: 1 / 12 }); + Cookies.set("player_team_id", data.teamId.toString(), { expires: 1 / 12 }); } async function leave() { @@ -32,56 +122,415 @@ export default function PlayPage() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "leave", playerId }), }); + + Cookies.remove("player_room_code"); + Cookies.remove("player_id"); + Cookies.remove("player_name"); + Cookies.remove("player_team_id"); window.location.href = "/"; } - if (joined) { + if (roomClosed) { return ( -
-

✓ You're in!

-

- Room: {code} -

-

Waiting for host to start...

- +
+
+ {[...Array(20)].map((_, i) => ( +
+ ))} +
+ +
+
😔
+

+ Room Closed +

+

+ The host has ended this game session +

+ + Return Home + +
); } + if (!joined) { + return ( +
+
+ {[...Array(30)].map((_, i) => ( +
+ ))} +
+ +
+

+ Join Game +

+ +
+
+ + setName(e.target.value)} + className="w-full p-4 text-lg bg-[var(--ctp-surface1)] border-2 border-[var(--ctp-surface2)] rounded-xl text-[var(--ctp-text)] focus:border-[var(--ctp-mauve)] focus:outline-none transition-all placeholder-[var(--ctp-overlay0)]" + maxLength={20} + /> +
+ +
+ + setCode(e.target.value.toUpperCase())} + className="w-full p-4 text-2xl font-bold bg-[var(--ctp-surface1)] border-2 border-[var(--ctp-surface2)] rounded-xl text-[var(--ctp-text)] focus:border-[var(--ctp-blue)] focus:outline-none transition-all uppercase tracking-widest text-center placeholder-[var(--ctp-overlay0)]" + maxLength={6} + /> +
+ +
+ + + +
+ + +
+
+
+ ); + } + + const phase = gameState?.phase || "lobby"; + const teamColor = teamId === "1" ? "blue" : "red"; + return ( -
-
-

Join a Room

+
+
+ {[...Array(25)].map((_, i) => ( +
+ ))} +
- setName(e.target.value)} - className="w-full p-4 text-lg border-2 border-gray-300 rounded-lg mb-4" - maxLength={20} - /> - - setCode(e.target.value.toUpperCase())} - className="w-full p-4 text-lg border-2 border-gray-300 rounded-lg mb-6 uppercase" - maxLength={6} - /> - - +
+
+
+
{teamId === "1" ? "🔵" : "🔴"}
+

+ {name} +

+
+
+
+ Team {teamId} +
+
+ Room: {code} +
+
+
+ +
+
+ Your Score +
+
+ {myScore} +
+
+
+
+ + {/* Game Status */} +
+ {phase === "lobby" && ( +
+
+

+ Waiting for Game Start... +

+

+ Get ready to buzz in! +

+
+
+
+
+
+ )} + + {phase === "intermission" && ( +
+
📚
+

+ Next Question Loading... +

+

+ Stay focused and ready! +

+
+ )} + + {(phase === "tossup_reading" || phase === "tossup_buzzing") && ( +
+
+
+ TOSSUP QUESTION +
+

+ Listen Carefully! +

+
+ + {canBuzz && !buzzing ? ( +
+ +

+ Press{" "} + + SPACE + {" "} + to buzz +

+
+ ) : buzzing ? ( +
+
+
+
+

+ Buzzed! +

+
+ ) : gameState?.buzzedPlayer === playerId ? ( +
+
+
+
+
You Buzzed!
+
+
+

+ Waiting for moderator judgment... +

+
+ ) : gameState?.buzzedPlayer ? ( +
+
+
+
🔔
+
+ Someone +
+ Buzzed +
+
+
+

+ Waiting for answer... +

+
+ ) : ( +
+
+
+
👂
+
+ Listen... +
+
+
+

+ Question being read... +

+
+ )} +
+ )} + + {phase === "bonus" && ( +
+
+
+ BONUS QUESTION +
+

+ {gameState?.currentTeam === parseInt(teamId) + ? "Your Team Bonus!" + : "Other Team Bonus"} +

+
+ + {gameState?.currentTeam === parseInt(teamId) ? ( +
+
🎯
+

+ Work together with your team! +

+
+

+ +10 points if correct +

+
+
+ ) : ( +
+
⏸️
+

+ Waiting for other team... +

+
+ )} +
+ )} + + {phase === "finished" && ( +
+
🏁
+

+ Game Over! +

+
+

+ Your Final Score +

+

+ {myScore} +

+
+
+ )} +
+ + {/* Leave Button */} +
+ +
); diff --git a/src/components/Timer.tsx b/src/components/Timer.tsx new file mode 100644 index 0000000..5dd2a7d --- /dev/null +++ b/src/components/Timer.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface TimerProps { + startTime: number | null; + duration: number; + onExpire?: () => void; + showWarning?: boolean; +} + +export default function Timer({ + startTime, + duration, + onExpire, + showWarning = true, +}: TimerProps) { + const [remaining, setRemaining] = useState(duration); + + useEffect(() => { + if (!startTime) { + setRemaining(duration); + return; + } + + const interval = setInterval(() => { + const elapsed = Math.floor((Date.now() - startTime) / 1000); + const left = Math.max(0, duration - elapsed); + setRemaining(left); + + if (left === 0 && onExpire) { + onExpire(); + } + }, 100); + + return () => clearInterval(interval); + }, [startTime, duration, onExpire]); + + const percentage = (remaining / duration) * 100; + const isWarning = remaining <= 10; + const isCritical = remaining <= 5; + + return ( +
+
+
+ + + {remaining}s + +
+ + / {duration}s + +
+ +
+
+
+
+ + {showWarning && isCritical && ( +
+
+ ⚠️ TIME RUNNING OUT! +
+
+ )} +
+
+ ); +} diff --git a/src/lib/rooms.ts b/src/lib/rooms.ts index 5256eb0..4549783 100644 --- a/src/lib/rooms.ts +++ b/src/lib/rooms.ts @@ -1,11 +1,9 @@ -import { Room } from '@/types'; +import { Room, Player, GameState } from '@/types'; -// Use global to persist across Next.js hot reloads declare global { var roomStorage: Map | undefined; } -// Initialize global storage if it doesn't exist if (!global.roomStorage) { global.roomStorage = new Map(); } @@ -23,7 +21,21 @@ function generateId(): string { return Math.random().toString(36).substring(2, 15); } -export function createRoom() { +function createInitialGameState(): GameState { + return { + phase: 'lobby', + currentQuestion: null, + currentTeam: null, + buzzedPlayer: null, + tossupStartTime: null, + bonusStartTime: null, + tossupInterrupted: false, + questionNumber: 0, + totalQuestions: 20, + }; +} + +export function createRoom(teamMode: 1 | 2 = 2) { const code = generateCode(); const moderatorId = generateId(); @@ -31,37 +43,53 @@ export function createRoom() { code, moderatorId, players: new Map(), + teams: new Map([ + [1, { id: 1, score: 0, players: [] }], + [2, { id: 2, score: 0, players: [] }], + ]), + gameState: createInitialGameState(), isActive: true, createdAt: new Date(), + teamMode, }); - console.log('✅ Created room:', code, 'Total rooms:', rooms.size); + console.log('✅ Created room:', code, 'Mode:', teamMode === 1 ? 'Individual' : 'Team'); return { code, moderatorId }; } -export function joinRoom(code: string, name: string) { +export function joinRoom(code: string, name: string, teamId: 1 | 2) { const room = rooms.get(code); - console.log('👤 Join attempt for room:', code, 'Room exists:', !!room, 'Total rooms:', rooms.size); if (!room?.isActive) return null; + if (room.gameState.phase !== 'lobby') return null; const playerId = generateId(); - room.players.set(playerId, { id: playerId, name, score: 0 }); - console.log('✅ Player joined:', name, 'Total players:', room.players.size); - return { playerId }; + // In individual mode, force everyone to team 1 + const actualTeamId = room.teamMode === 1 ? 1 : teamId; + + const player: Player = { + id: playerId, + name, + score: 0, + teamId: actualTeamId, + }; + + room.players.set(playerId, player); + room.teams.get(actualTeamId)?.players.push(playerId); + + console.log('✅ Player joined:', name, 'Team:', actualTeamId, '(Mode:', room.teamMode === 1 ? 'Individual' : 'Team', ')'); + return { playerId, teamId: actualTeamId }; } export function getRoom(code: string) { - const room = rooms.get(code); - console.log('🔍 Get room:', code, 'Exists:', !!room, 'Total rooms:', rooms.size); - return room; + return rooms.get(code); } export function closeRoom(code: string, moderatorId: string) { const room = rooms.get(code); if (room?.moderatorId === moderatorId) { - room.isActive = false; + rooms.delete(code); console.log('🔒 Room closed:', code); return true; } @@ -70,14 +98,193 @@ export function closeRoom(code: string, moderatorId: string) { export function leaveRoom(code: string, playerId: string) { const room = rooms.get(code); - const result = room?.players.delete(playerId) ?? false; - if (result) { - console.log('👋 Player left room:', code, 'Remaining players:', room?.players.size); + if (!room) return false; + + const player = room.players.get(playerId); + if (player) { + const team = room.teams.get(player.teamId); + if (team) { + team.players = team.players.filter(id => id !== playerId); + } } - return result; + + room.players.delete(playerId); + + if (room.players.size === 0) { + rooms.delete(code); + console.log('🗑️ Room deleted (empty):', code); + } + + return true; } -// Auto-cleanup inactive rooms after 2 hours +// Game actions +export function startGame(code: string, moderatorId: string) { + const room = rooms.get(code); + if (!room || room.moderatorId !== moderatorId) return false; + + room.gameState.phase = 'intermission'; + console.log('🎮 Game started:', code); + return true; +} + +export function loadQuestion(code: string, moderatorId: string, question: any) { + const room = rooms.get(code); + if (!room || room.moderatorId !== moderatorId) return false; + + room.gameState.currentQuestion = question; + room.gameState.questionNumber++; + room.gameState.phase = 'tossup_reading'; + room.gameState.tossupStartTime = null; + room.gameState.bonusStartTime = null; + room.gameState.buzzedPlayer = null; + room.gameState.currentTeam = null; + room.gameState.tossupInterrupted = false; + + console.log('📖 Question loaded:', room.gameState.questionNumber); + return true; +} + +export function startTossupTimer(code: string, moderatorId: string) { + const room = rooms.get(code); + if (!room || room.moderatorId !== moderatorId) return false; + + room.gameState.phase = 'tossup_buzzing'; + room.gameState.tossupStartTime = Date.now(); + + console.log('⏱️ Tossup timer started'); + return true; +} + +export function startBonusTimer(code: string, moderatorId: string) { + const room = rooms.get(code); + if (!room || room.moderatorId !== moderatorId) return false; + if (room.gameState.phase !== 'bonus') return false; + + room.gameState.bonusStartTime = Date.now(); + + console.log('⏱️ Bonus timer started'); + return true; +} + +export function buzz(code: string, playerId: string) { + const room = rooms.get(code); + + // Can buzz during reading OR buzzing phase + if (!room || (room.gameState.phase !== 'tossup_reading' && room.gameState.phase !== 'tossup_buzzing')) { + return false; + } + + if (room.gameState.buzzedPlayer) return false; // Already buzzed + + const player = room.players.get(playerId); + if (!player) return false; + + room.gameState.buzzedPlayer = playerId; + + // Interrupted = buzzed BEFORE timer started + room.gameState.tossupInterrupted = room.gameState.phase === 'tossup_reading'; + + // Move to buzzing phase if we were still reading + if (room.gameState.phase === 'tossup_reading') { + room.gameState.phase = 'tossup_buzzing'; + } + + console.log('🔔 Buzz from:', player.name, 'Interrupted:', room.gameState.tossupInterrupted); + return true; +} + +export function markBonusCorrect(code: string, moderatorId: string) { + const room = rooms.get(code); + if (!room || room.moderatorId !== moderatorId) return false; + if (room.gameState.phase !== 'bonus') return false; + if (!room.gameState.currentTeam) return false; + + const team = room.teams.get(room.gameState.currentTeam); + if (team) { + team.score += 10; + // DO NOT add to individual scores - bonus doesn't count for individuals + } + + console.log('✅ Bonus correct! +10 to team', room.gameState.currentTeam); + room.gameState.phase = 'intermission'; + + return true; +} +export function markTossupCorrect(code: string, moderatorId: string) { + const room = rooms.get(code); + if (!room || room.moderatorId !== moderatorId) return false; + if (!room.gameState.buzzedPlayer) return false; + + const player = room.players.get(room.gameState.buzzedPlayer); + if (!player) return false; + + // Award points + player.score += 4; + const team = room.teams.get(player.teamId); + if (team) team.score += 4; + + // Move to bonus (but DON'T start timer yet) + room.gameState.currentTeam = player.teamId; + room.gameState.phase = 'bonus'; + room.gameState.bonusStartTime = null; // Timer starts when moderator clicks button + + console.log('✅ Tossup correct! Team', player.teamId, 'gets bonus'); + return true; +} + +export function markTossupWrong(code: string, moderatorId: string) { + const room = rooms.get(code); + if (!room || room.moderatorId !== moderatorId) return false; + if (!room.gameState.buzzedPlayer) return false; + + const player = room.players.get(room.gameState.buzzedPlayer); + if (!player) return false; + + // If interrupted, penalty to individual player and team + if (room.gameState.tossupInterrupted) { + player.score -= 4; // Individual penalty + + const buzzTeam = room.teams.get(player.teamId); + if (buzzTeam) buzzTeam.score -= 4; + + const otherTeamId = player.teamId === 1 ? 2 : 1; + const otherTeam = room.teams.get(otherTeamId); + if (otherTeam) otherTeam.score += 4; + + console.log('❌ Wrong interrupt! -4 to', player.name, 'and team', player.teamId, '+4 to team', otherTeamId); + } else { + console.log('❌ Wrong answer (no penalty)'); + } + + room.gameState.phase = 'intermission'; + room.gameState.buzzedPlayer = null; + + return true; +} + + +export function markBonusWrong(code: string, moderatorId: string) { + const room = rooms.get(code); + if (!room || room.moderatorId !== moderatorId) return false; + + console.log('❌ Bonus wrong'); + room.gameState.phase = 'intermission'; + + return true; +} + +export function endGame(code: string, moderatorId: string) { + const room = rooms.get(code); + if (!room || room.moderatorId !== moderatorId) return false; + + room.gameState.phase = 'finished'; + console.log('🏁 Game finished'); + + return true; +} + +// Cleanup setInterval(() => { const now = Date.now(); const twoHours = 2 * 60 * 60 * 1000; @@ -89,4 +296,4 @@ setInterval(() => { console.log('🗑️ Cleaned up room:', code); } } -}, 300000); // Check every 5 minutes \ No newline at end of file +}, 300000); \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index aa212bc..0d7ce3d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,12 +16,34 @@ export interface Player { id: string; name: string; score: number; + teamId: 1 | 2; +} + +export interface Team { + id: 1 | 2; + score: number; + players: string[]; // player IDs +} + +export interface GameState { + phase: 'lobby' | 'tossup_reading' | 'tossup_buzzing' | 'bonus' | 'intermission' | 'finished'; + currentQuestion: Question | null; + currentTeam: 1 | 2 | null; // Team that buzzed in correctly + buzzedPlayer: string | null; // Player ID who buzzed + tossupStartTime: number | null; + bonusStartTime: number | null; + tossupInterrupted: boolean; + questionNumber: number; + totalQuestions: number; } export interface Room { code: string; moderatorId: string; players: Map; + teams: Map<1 | 2, Team>; + gameState: GameState; isActive: boolean; createdAt: Date; + teamMode: 1 | 2; // 1 = individual scoring, 2 = team mode } \ No newline at end of file