diff --git a/App.tsx b/App.tsx index 5f78901..25c7fde 100644 --- a/App.tsx +++ b/App.tsx @@ -1,28 +1,39 @@ import React, { useState, useEffect, Suspense } from 'react'; -import { GameProvider } from './context/GameContext'; -import { Monitor, Smartphone, LayoutDashboard, Loader2 } from 'lucide-react'; +import { GameProvider, useGame } from './context/GameContext'; +import { Monitor, Smartphone, LayoutDashboard, Loader2, RefreshCw } from 'lucide-react'; // Lazy load components to reduce initial bundle size const HostView = React.lazy(() => import('./components/HostView').then(module => ({ default: module.HostView }))); const PlayerView = React.lazy(() => import('./components/PlayerView').then(module => ({ default: module.PlayerView }))); const SpectatorView = React.lazy(() => import('./components/SpectatorView').then(module => ({ default: module.SpectatorView }))); -const App: React.FC = () => { +// Inner Component to access Context +const AppContent: React.FC = () => { + const { setIsHost, isSyncing, isHost } = useGame(); const [view, setView] = useState<'HOST' | 'PLAYER' | 'SPECTATOR'>('SPECTATOR'); // Simple hash routing for demo purposes useEffect(() => { const handleHashChange = () => { const hash = window.location.hash; - if (hash === '#host') setView('HOST'); - else if (hash === '#player') setView('PLAYER'); - else setView('SPECTATOR'); + if (hash === '#host') { + setView('HOST'); + setIsHost(true); // Promote this session to Host Authority + } + else if (hash === '#player') { + setView('PLAYER'); + setIsHost(false); + } + else { + setView('SPECTATOR'); + setIsHost(false); + } }; handleHashChange(); window.addEventListener('hashchange', handleHashChange); return () => window.removeEventListener('hashchange', handleHashChange); - }, []); + }, [setIsHost]); const navigate = (newView: 'HOST' | 'PLAYER' | 'SPECTATOR') => { setView(newView); @@ -39,13 +50,15 @@ const App: React.FC = () => { }; return ( -
{/* Top Navigation Bar */}
+ ); +}; + +const App: React.FC = () => { + return ( + + ); }; @@ -107,4 +127,4 @@ const NavButton: React.FC = ({ active, onClick, icon, label }) = ); -export default App; \ No newline at end of file +export default App; diff --git a/api.php b/api.php new file mode 100644 index 0000000..81e052d --- /dev/null +++ b/api.php @@ -0,0 +1,81 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]; + +try { + $pdo = new PDO($dsn, $user, $pass, $options); +} catch (\PDOException $e) { + // Fallback for when DB isn't configured yet so app doesn't crash completely + echo json_encode(['error' => 'Database connection failed: ' . $e->getMessage()]); + exit; +} + +// Handle Preflight +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + exit(0); +} + +$action = $_GET['action'] ?? ''; +$input = json_decode(file_get_contents('php://input'), true); + +try { + if ($_SERVER['REQUEST_METHOD'] === 'GET') { + if ($action === 'getState') { + $stmt = $pdo->query("SELECT game_data FROM game_state WHERE id = 1"); + $row = $stmt->fetch(); + echo $row['game_data'] ?: '{}'; + } + elseif ($action === 'getIntents') { + // Transaction to read and delete to ensure processed once + $pdo->beginTransaction(); + $stmt = $pdo->query("SELECT * FROM player_intents ORDER BY created_at ASC"); + $intents = $stmt->fetchAll(); + if ($intents) { + $pdo->exec("DELETE FROM player_intents"); // Clear queue after reading + } + $pdo->commit(); + + // Parse payloads + foreach ($intents as &$intent) { + $intent['payload'] = json_decode($intent['payload'], true); + } + echo json_encode($intents); + } + } + elseif ($_SERVER['REQUEST_METHOD'] === 'POST') { + if ($action === 'pushState') { + $data = json_encode($input); + $stmt = $pdo->prepare("UPDATE game_state SET game_data = ? WHERE id = 1"); + $stmt->execute([$data]); + echo json_encode(['success' => true]); + } + elseif ($action === 'pushIntent') { + $type = $input['type']; + $payload = json_encode($input['payload']); + $stmt = $pdo->prepare("INSERT INTO player_intents (type, payload) VALUES (?, ?)"); + $stmt->execute([$type, $payload]); + echo json_encode(['success' => true]); + } + } +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} +?> \ No newline at end of file diff --git a/components/HostView.tsx b/components/HostView.tsx index 5bd83c5..50bec51 100644 --- a/components/HostView.tsx +++ b/components/HostView.tsx @@ -3,13 +3,13 @@ import { useGame } from '../context/GameContext'; import { GamePhase } from '../types'; import type { Question } from '../types'; import { Soundboard } from './Soundboard'; -import { Play, SkipForward, CheckCircle, XCircle, Users, Library, Sparkles, Plus, Trash2, Edit, ArrowLeft, Upload, RefreshCw, Image as ImageIcon, List, Trophy, RotateCcw } from 'lucide-react'; +import { Play, SkipForward, CheckCircle, XCircle, Users, Library, Sparkles, Plus, Trash2, Edit, ArrowLeft, Upload, RefreshCw, Image as ImageIcon, List, Trophy, RotateCcw, QrCode } from 'lucide-react'; import { generateQuestions } from '../services/geminiService'; export const HostView: React.FC = () => { const { - gameState, activeGameName, players, teams, buzzQueue, questions, games, - approvePlayer, startGame, startCountdown, openBuzzers, + gameState, activeGameName, players, teams, buzzQueue, questions, games, joinUrl, + setJoinUrl, approvePlayer, startGame, startCountdown, openBuzzers, resolveBuzz, rectifyBuzz, skipQuestion, nextPhase, resetGame, createGame, updateGame, deleteGame, loadGameToLive } = useGame(); @@ -189,6 +189,23 @@ export const HostView: React.FC = () => { {/* GAME CONTROL TAB */} {activeTab === 'GAME' && (
+ {/* Session Settings */} +
+
+ +
+
+ + setJoinUrl(e.target.value)} + className="w-full font-mono text-sm border-b border-slate-300 focus:border-indigo-600 outline-none bg-transparent py-1 text-slate-800" + placeholder="https://..." + /> +
+
+ {/* LAST QUESTION WARNING */} {isLastQuestionPhase && !isGameOver && (
diff --git a/components/SpectatorView.tsx b/components/SpectatorView.tsx index 46f6da4..6d5caad 100644 --- a/components/SpectatorView.tsx +++ b/components/SpectatorView.tsx @@ -6,7 +6,7 @@ import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from import { Trophy, Zap, Users } from 'lucide-react'; export const SpectatorView: React.FC = () => { - const { gameState, questions, teams, players, buzzQueue } = useGame(); + const { gameState, questions, teams, players, buzzQueue, joinUrl } = useGame(); const currentQ = questions[gameState.currentQuestionIndex]; // Helper to get formatted leaderboard data @@ -29,10 +29,11 @@ export const SpectatorView: React.FC = () => {

JOIN THE QUIZ

-
- QR Code +
+ QR Code
-

Scan to Join

+

Scan to Join

+

{joinUrl}

{players.filter(p => p.isApproved).map(p => ( diff --git a/context/GameContext.tsx b/context/GameContext.tsx index 9952f27..1b87f9c 100644 --- a/context/GameContext.tsx +++ b/context/GameContext.tsx @@ -1,22 +1,29 @@ -import React, { createContext, useContext, useState, useEffect } from 'react'; +import React, { createContext, useContext, useState, useEffect, useRef } from 'react'; import { GamePhase } from '../types'; -import type { Player, Team, Question, BuzzerLog, GameState, Game } from '../types'; +import type { Player, Team, Question, BuzzerLog, GameState, Game, PlayerIntent } from '../types'; interface GameContextType { // State gameState: GameState; - activeGameName: string; // New: Track which game is loaded + activeGameName: string; + joinUrl: string; players: Player[]; teams: Team[]; - questions: Question[]; // The ACTIVE questions currently being played - games: Game[]; // The LIBRARY of saved games + questions: Question[]; + games: Game[]; buzzQueue: BuzzerLog[]; currentPlayerId: string | null; + // Sync Status + isHost: boolean; + setIsHost: (isHost: boolean) => void; + isSyncing: boolean; + // Actions + setJoinUrl: (url: string) => void; addPlayer: (name: string, teamName: string) => void; approvePlayer: (playerId: string) => void; - removePlayer: (playerId: string) => void; // New action + removePlayer: (playerId: string) => void; startGame: () => void; startCountdown: () => void; openBuzzers: () => void; @@ -39,6 +46,11 @@ interface GameContextType { const GameContext = createContext(undefined); +// --- API CONFIG --- +// Assuming api.php is at the root. Change if in a subfolder. +// If using Vite development server, you might need to point this to your actual PHP server URL. +const API_URL = import.meta.env.DEV ? 'http://localhost/quiz/api.php' : './api.php'; + // Initial Mock Data const INITIAL_QUESTIONS: Question[] = [ { id: '1', text: "What is the capital of France?", answer: "Paris", points: 10, category: "Geography" }, @@ -68,6 +80,10 @@ const INITIAL_GAMES: Game[] = [ ]; export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [isHost, setIsHost] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + + // --- GAME STATE --- const [gameState, setGameState] = useState({ phase: GamePhase.LOBBY, currentQuestionIndex: -1, @@ -76,6 +92,7 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children }); const [activeGameName, setActiveGameName] = useState("General Knowledge Demo"); + const [joinUrl, setJoinUrl] = useState(''); const [players, setPlayers] = useState([]); const [teams, setTeams] = useState([]); const [questions, setQuestionsQuestions] = useState(INITIAL_QUESTIONS); @@ -83,7 +100,143 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children const [buzzQueue, setBuzzQueue] = useState([]); const [currentPlayerId, setCurrentPlayerId] = useState(null); - // Helper: Find or create team + // Refs for accessing state inside intervals without dependencies + const stateRef = useRef({ gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl }); + useEffect(() => { + stateRef.current = { gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl }; + }, [gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl]); + + // Set default Join URL on mount + useEffect(() => { + if (typeof window !== 'undefined') { + const url = `${window.location.origin}${window.location.pathname}#player`; + setJoinUrl(url); + } + }, []); + + // --- SYNC ENGINE --- + useEffect(() => { + const syncInterval = setInterval(async () => { + setIsSyncing(true); + try { + if (isHost) { + // --- HOST LOGIC: PULL INTENTS -> PROCESS -> PUSH STATE --- + + // 1. Fetch Intents + const response = await fetch(`${API_URL}?action=getIntents`); + const intents = await response.json(); + + let stateChanged = false; + + // 2. Process Intents + if (Array.isArray(intents) && intents.length > 0) { + console.log("Processing Intents:", intents); + intents.forEach((item: any) => { + const { type, payload } = item; + + if (type === 'JOIN') { + // Logic extracted from addPlayer + const { name, teamName, tempId } = payload; + const currentTeams = stateRef.current.teams; + const existingTeam = currentTeams.find(t => t.name.toLowerCase() === teamName.toLowerCase()); + + let teamId = existingTeam?.id; + if (!existingTeam) { + const newTeam: Team = { id: crypto.randomUUID(), name: teamName, score: 0 }; + setTeams(prev => [...prev, newTeam]); + teamId = newTeam.id; + } + + // Check if player already exists to prevent dupes + const exists = stateRef.current.players.some(p => p.name === name && p.teamId === teamId); + if (!exists && teamId) { + const newPlayer: Player = { + id: tempId || crypto.randomUUID(), + name, + teamId, + score: 0, + isApproved: true, // Auto approve for now for smoother UX + stats: { correctAnswers: 0, totalBuzzes: 0, bestReactionTime: null } + }; + setPlayers(prev => [...prev, newPlayer]); + } + stateChanged = true; + } + else if (type === 'BUZZ') { + const { playerId } = payload; + const currentState = stateRef.current.gameState; + const currentQueue = stateRef.current.buzzQueue; + + if (currentState.phase === GamePhase.BUZZER_OPEN && !currentQueue.find(b => b.playerId === playerId)) { + const newBuzz: BuzzerLog = { + playerId, + timestamp: Date.now(), + order: currentQueue.length + 1, + status: 'PENDING' + }; + setBuzzQueue(prev => { + const updated = [...prev, newBuzz]; + if (updated.length === 1) { // Only force update phase if it was the first buzz + setGameState(gs => ({ ...gs, phase: GamePhase.ADJUDICATION })); + } + return updated; + }); + stateChanged = true; + } + } + else if (type === 'LEAVE') { + const { playerId } = payload; + setPlayers(prev => prev.filter(p => p.id !== playerId)); + stateChanged = true; + } + }); + } + + // 3. Push State (Always push to keep server alive with latest data, or at least periodically) + // For now, we push every cycle to ensure consistency. + const fullState = { + gameState: stateRef.current.gameState, + players: stateRef.current.players, + teams: stateRef.current.teams, + questions: stateRef.current.questions, + activeGameName: stateRef.current.activeGameName, + buzzQueue: stateRef.current.buzzQueue, + joinUrl: stateRef.current.joinUrl + }; + + await fetch(`${API_URL}?action=pushState`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fullState) + }); + + } else { + // --- CLIENT LOGIC: PULL STATE -> UPDATE LOCAL --- + const response = await fetch(`${API_URL}?action=getState`); + const remoteState = await response.json(); + + if (remoteState && remoteState.gameState) { + setGameState(remoteState.gameState); + setPlayers(remoteState.players || []); + setTeams(remoteState.teams || []); + setQuestionsQuestions(remoteState.questions || []); + setActiveGameName(remoteState.activeGameName || ""); + setBuzzQueue(remoteState.buzzQueue || []); + setJoinUrl(remoteState.joinUrl || ""); + } + } + } catch (e) { + console.error("Sync Error:", e); + } finally { + setIsSyncing(false); + } + }, 500); // 500ms polling rate + + return () => clearInterval(syncInterval); + }, [isHost]); // Re-run effect if role changes + + + // --- HOST HELPERS (Local) --- const getOrCreateTeam = (name: string) => { const existing = teams.find(t => t.name.toLowerCase() === name.toLowerCase()); if (existing) return existing; @@ -92,37 +245,72 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children return newTeam; }; - const addPlayer = (name: string, teamName: string) => { - const team = getOrCreateTeam(teamName); - const newPlayer: Player = { - id: crypto.randomUUID(), - name, - teamId: team.id, - score: 0, - isApproved: false, // Requires admin approval - stats: { - correctAnswers: 0, - totalBuzzes: 0, - bestReactionTime: null + // --- ACTIONS --- + + const sendIntent = async (type: string, payload: any) => { + try { + await fetch(`${API_URL}?action=pushIntent`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, payload }) + }); + } catch (e) { + console.error("Failed to send intent", e); } - }; - setPlayers(prev => [...prev, newPlayer]); - if (!currentPlayerId) setCurrentPlayerId(newPlayer.id); + }; + + const addPlayer = (name: string, teamName: string) => { + // If Host, do it immediately (Old Logic) - Although now logic is in Sync Loop + // To make it unified, Host also sends intent OR we just trust the sync loop. + // BUT, for "Host-Added Players", we can just do it locally. + // For "Client Joining", they send intent. + + // PLAYER-SIDE LOGIC: + const tempId = crypto.randomUUID(); + setCurrentPlayerId(tempId); // Set ID immediately so UI shows "Waiting..." + sendIntent('JOIN', { name, teamName, tempId }); }; const removePlayer = (playerId: string) => { - setPlayers(prev => prev.filter(p => p.id !== playerId)); - if (currentPlayerId === playerId) { - setCurrentPlayerId(null); + if (isHost) { + setPlayers(prev => prev.filter(p => p.id !== playerId)); + } else { + // Player leaving + sendIntent('LEAVE', { playerId }); + if (currentPlayerId === playerId) setCurrentPlayerId(null); } }; const approvePlayer = (playerId: string) => { + if (!isHost) return; setPlayers(prev => prev.map(p => p.id === playerId ? { ...p, isApproved: true } : p)); }; + const handleBuzz = (playerId: string) => { + if (isHost) { + // Host manual buzz? Rare, but allowed. + if (gameState.phase !== GamePhase.BUZZER_OPEN) return; + if (buzzQueue.find(b => b.playerId === playerId)) return; + const newBuzz: BuzzerLog = { + playerId, + timestamp: Date.now(), + order: buzzQueue.length + 1, + status: 'PENDING' + }; + setBuzzQueue(prev => [...prev, newBuzz]); + if (buzzQueue.length === 0) { + setGameState(prev => ({ ...prev, phase: GamePhase.ADJUDICATION })); + } + } else { + // Client buzz + sendIntent('BUZZ', { playerId }); + } + }; + + // --- HOST ONLY ACTIONS (No change needed, just guard them) --- + const startGame = () => { - // Directly start Q1 Countdown, skipping initial Leaderboard + if (!isHost) return; setGameState({ phase: GamePhase.COUNTDOWN, currentQuestionIndex: 0, @@ -133,16 +321,15 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children }; const startCountdown = () => { + if (!isHost) return; let nextIndex = gameState.currentQuestionIndex; if (gameState.phase === GamePhase.LEADERBOARD || gameState.phase === GamePhase.LOBBY) { nextIndex = gameState.currentQuestionIndex + 1; } - if (nextIndex >= questions.length) { setGameState(prev => ({ ...prev, phase: GamePhase.FINAL_STATS })); return; } - setGameState({ phase: GamePhase.COUNTDOWN, currentQuestionIndex: nextIndex, @@ -152,7 +339,9 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children setBuzzQueue([]); }; + // Countdown Timer Effect (Runs on Host Only to drive state) useEffect(() => { + if (!isHost) return; let timer: ReturnType; if (gameState.phase === GamePhase.COUNTDOWN) { if (gameState.countdownValue > 0) { @@ -164,60 +353,32 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children } } return () => clearTimeout(timer); - }, [gameState.phase, gameState.countdownValue]); + }, [gameState.phase, gameState.countdownValue, isHost]); const openBuzzers = () => { + if (!isHost) return; setGameState(prev => ({ ...prev, phase: GamePhase.BUZZER_OPEN, buzzerOpenTimestamp: Date.now() })); }; - const handleBuzz = (playerId: string) => { - if (gameState.phase !== GamePhase.BUZZER_OPEN) return; - if (buzzQueue.find(b => b.playerId === playerId)) return; - - const newBuzz: BuzzerLog = { - playerId, - timestamp: Date.now(), - order: buzzQueue.length + 1, - status: 'PENDING' - }; - - setBuzzQueue(prev => [...prev, newBuzz]); - - if (buzzQueue.length === 0) { - setGameState(prev => ({ ...prev, phase: GamePhase.ADJUDICATION })); - } - }; - const resolveBuzz = (playerId: string, correct: boolean) => { + if (!isHost) return; const currentQ = questions[gameState.currentQuestionIndex]; const player = players.find(p => p.id === playerId); - // Update Stats logic setPlayers(prev => prev.map(p => { if (p.id !== playerId) return p; - let newStats = { ...p.stats }; - // Increment attempts (total buzzes) newStats.totalBuzzes += 1; - - // Calculate reaction time if this buzz was the first one processed - // We only really care about updating reaction time if it was a successful/valid buzz attempt if (gameState.buzzerOpenTimestamp) { const reactionTime = Date.now() - gameState.buzzerOpenTimestamp; if (newStats.bestReactionTime === null || reactionTime < newStats.bestReactionTime) { newStats.bestReactionTime = reactionTime; } } - if (correct) { newStats.correctAnswers += 1; - return { - ...p, - score: p.score + currentQ.points, - stats: newStats - }; + return { ...p, score: p.score + currentQ.points, stats: newStats }; } - return { ...p, stats: newStats }; })); @@ -237,6 +398,7 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children }; const rectifyBuzz = (playerId: string, newStatus: 'CORRECT' | 'WRONG') => { + if (!isHost) return; const currentQ = questions[gameState.currentQuestionIndex]; const player = players.find(p => p.id === playerId); if (!player || !player.teamId) return; @@ -250,43 +412,38 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children if (newStatus === 'CORRECT' && oldBuzz.status !== 'CORRECT') { setTeams(prev => prev.map(t => t.id === player.teamId ? { ...t, score: t.score + points } : t)); - // Also correct stat if rectifying setPlayers(prev => prev.map(p => p.id === playerId ? { - ...p, - score: p.score + points, - stats: { ...p.stats, correctAnswers: p.stats.correctAnswers + 1 } + ...p, score: p.score + points, stats: { ...p.stats, correctAnswers: p.stats.correctAnswers + 1 } } : p)); - setGameState(prev => ({ ...prev, phase: GamePhase.ANSWER_REVEAL })); } else if (newStatus === 'WRONG' && oldBuzz.status === 'CORRECT') { setTeams(prev => prev.map(t => t.id === player.teamId ? { ...t, score: t.score - points } : t)); setPlayers(prev => prev.map(p => p.id === playerId ? { - ...p, - score: p.score - points, - stats: { ...p.stats, correctAnswers: Math.max(0, p.stats.correctAnswers - 1) } + ...p, score: p.score - points, stats: { ...p.stats, correctAnswers: Math.max(0, p.stats.correctAnswers - 1) } } : p)); - const othersPending = buzzQueue.some(b => b.playerId !== playerId && b.status === 'PENDING'); setGameState(prev => ({ - ...prev, - phase: othersPending || buzzQueue.length === 0 ? GamePhase.BUZZER_OPEN : GamePhase.ADJUDICATION + ...prev, phase: othersPending || buzzQueue.length === 0 ? GamePhase.BUZZER_OPEN : GamePhase.ADJUDICATION })); } }; const skipQuestion = () => { + if (!isHost) return; setGameState(prev => ({ ...prev, phase: GamePhase.ANSWER_REVEAL })); setBuzzQueue([]); }; const nextPhase = () => { + if (!isHost) return; if (gameState.phase === GamePhase.ANSWER_REVEAL) { setGameState(prev => ({ ...prev, phase: GamePhase.LEADERBOARD })); } }; const playAudio = (url: string, start: number = 0, end?: number) => { + // Audio is mostly local for now const audio = new Audio(url); audio.currentTime = start; audio.play(); @@ -298,34 +455,24 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children }; const resetGame = () => { - // "Soft Reset" - Keep players and teams, but reset their scores and stats + if (!isHost) return; setGameState({ phase: GamePhase.LOBBY, currentQuestionIndex: -1, countdownValue: 3, buzzerOpenTimestamp: null }); - - // Reset Scores and Stats for Players setPlayers(prev => prev.map(p => ({ - ...p, - score: 0, - stats: { - correctAnswers: 0, - totalBuzzes: 0, - bestReactionTime: null - } + ...p, score: 0, stats: { correctAnswers: 0, totalBuzzes: 0, bestReactionTime: null } }))); - - // Reset Team Scores setTeams(prev => prev.map(t => ({ ...t, score: 0 }))); - setBuzzQueue([]); }; - // --- LIBRARY ACTIONS --- + // --- LIBRARY ACTIONS (Local to Host) --- const createGame = (name: string) => { + if (!isHost) return; const newGame: Game = { id: crypto.randomUUID(), name, @@ -336,19 +483,21 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children }; const updateGame = (gameId: string, updates: Partial) => { + if (!isHost) return; setGames(prev => prev.map(g => g.id === gameId ? { ...g, ...updates } : g)); }; const deleteGame = (gameId: string) => { + if (!isHost) return; setGames(prev => prev.filter(g => g.id !== gameId)); }; const loadGameToLive = (gameId: string) => { + if (!isHost) return; const game = games.find(g => g.id === gameId); if (game) { setQuestionsQuestions([...game.questions]); setActiveGameName(game.name); - setGameState({ phase: GamePhase.LOBBY, currentQuestionIndex: -1, @@ -356,7 +505,6 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children buzzerOpenTimestamp: null }); setBuzzQueue([]); - // NOTE: We do not clear players here anymore, allowing roster to persist between games } }; @@ -364,12 +512,17 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children { const context = useContext(GameContext); if (!context) throw new Error("useGame must be used within a GameProvider"); return context; -}; \ No newline at end of file +}; diff --git a/database.sql b/database.sql new file mode 100644 index 0000000..6966b39 --- /dev/null +++ b/database.sql @@ -0,0 +1,18 @@ + +CREATE TABLE IF NOT EXISTS `game_state` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `game_data` longtext NOT NULL, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `player_intents` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `type` varchar(50) NOT NULL, + `payload` longtext NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Initialize with one row +INSERT INTO `game_state` (`id`, `game_data`) VALUES (1, '{}') ON DUPLICATE KEY UPDATE `id`=1; diff --git a/types.ts b/types.ts index 73899fb..a49b5c5 100644 --- a/types.ts +++ b/types.ts @@ -1,3 +1,4 @@ + export const GamePhase = { LOBBY: 'LOBBY', COUNTDOWN: 'COUNTDOWN', @@ -66,4 +67,11 @@ export interface GameState { currentQuestionIndex: number; countdownValue: number; buzzerOpenTimestamp: number | null; // To calculate reaction time -} \ No newline at end of file +} + +// Synchronization Types +export interface PlayerIntent { + type: 'JOIN' | 'BUZZ' | 'LEAVE'; + payload: any; + created_at?: string; +}