diff --git a/backend/app/services/game_manager.py b/backend/app/services/game_manager.py index b96c9a0..3c5ed63 100644 --- a/backend/app/services/game_manager.py +++ b/backend/app/services/game_manager.py @@ -141,9 +141,17 @@ class GameManager: else: # Original team failed - enable steal + failed_team = room["current_team"] room["can_steal"] = True + + # Advance failed team's player index (they had their turn) + team_players = room["teams"][failed_team] + room["current_player_index"][failed_team] = ( + room["current_player_index"][failed_team] + 1 + ) % len(team_players) + # Switch to other team for potential steal - room["current_team"] = "B" if room["current_team"] == "A" else "A" + room["current_team"] = "B" if failed_team == "A" else "A" # Check if game is over (all questions answered) all_answered = all( diff --git a/backend/app/services/room_manager.py b/backend/app/services/room_manager.py index 97e6e56..38e65c1 100644 --- a/backend/app/services/room_manager.py +++ b/backend/app/services/room_manager.py @@ -168,6 +168,23 @@ class RoomManager: return json.loads(data) return None + async def update_player(self, socket_id: str, updates: dict) -> Optional[dict]: + """Update player info.""" + await self.connect() + data = await self.redis.get(f"player:{socket_id}") + if not data: + return None + + player = json.loads(data) + player.update(updates) + + await self.redis.setex( + f"player:{socket_id}", + 3600 * 3, + json.dumps(player) + ) + return player + async def get_player_stats(self, room_code: str, player_name: str) -> Optional[dict]: """Obtiene stats de un jugador.""" await self.connect() diff --git a/backend/app/sockets/game_events.py b/backend/app/sockets/game_events.py index e8e6f41..adf289a 100644 --- a/backend/app/sockets/game_events.py +++ b/backend/app/sockets/game_events.py @@ -1,5 +1,6 @@ import socketio import time +import json from datetime import datetime from app.services.room_manager import room_manager from app.services.game_manager import game_manager @@ -80,6 +81,105 @@ def register_socket_events(sio: socketio.AsyncServer): # Notify all players await sio.emit("player_joined", {"room": room}, room=room_code) + @sio.event + async def rejoin_room(sid, data): + """Rejoin an existing room after disconnect/refresh.""" + room_code = data.get("room_code", "").upper() + player_name = data.get("player_name", "") + team = data.get("team", "A") + + if not room_code or not player_name: + await sio.emit( + "rejoin_failed", + {"message": "Missing room code or player name"}, + to=sid + ) + return + + room = await room_manager.get_room(room_code) + if not room: + await sio.emit( + "rejoin_failed", + {"message": "Room not found or expired"}, + to=sid + ) + return + + # Check if player was in this room (by name) + player_found = False + player_team = None + for t in ["A", "B"]: + for i, p in enumerate(room["teams"][t]): + if p["name"] == player_name: + # Update socket_id for this player + room["teams"][t][i]["socket_id"] = sid + player_found = True + player_team = t + break + if player_found: + break + + if not player_found: + # Player not found, try to add them back to their preferred team + if len(room["teams"][team]) >= 4: + # Try other team + other_team = "B" if team == "A" else "A" + if len(room["teams"][other_team]) >= 4: + await sio.emit( + "rejoin_failed", + {"message": "Room is full"}, + to=sid + ) + return + team = other_team + + room["teams"][team].append({ + "name": player_name, + "team": team, + "position": len(room["teams"][team]), + "socket_id": sid + }) + player_team = team + + # Update room and player records + await room_manager.update_room(room_code, room) + await room_manager.update_player(sid, { + "name": player_name, + "room": room_code, + "team": player_team + }) + + # Also set new player record if it doesn't exist + existing = await room_manager.get_player(sid) + if not existing: + await room_manager.redis.setex( + f"player:{sid}", + 3600 * 3, + json.dumps({"name": player_name, "room": room_code, "team": player_team}) + ) + + # Join socket room + await sio.enter_room(sid, room_code) + + # Send current game state to rejoining player + await sio.emit( + "rejoin_success", + { + "room": room, + "player_name": player_name, + "team": player_team + }, + to=sid + ) + + # Notify others that player reconnected + await sio.emit( + "player_reconnected", + {"player_name": player_name, "team": player_team, "room": room}, + room=room_code, + skip_sid=sid + ) + @sio.event async def change_team(sid, data): """Switch player to another team.""" @@ -89,6 +189,11 @@ def register_socket_events(sio: socketio.AsyncServer): room_code = player["room"] new_team = data.get("team") + current_team = player["team"] + + # Don't do anything if already on that team + if current_team == new_team: + return room = await room_manager.get_room(room_code) if not room or len(room["teams"][new_team]) >= 4: @@ -99,12 +204,16 @@ def register_socket_events(sio: socketio.AsyncServer): ) return - # Remove from current team - current_team = player["team"] + # Remove from current team (by socket_id to be safe) room["teams"][current_team] = [ p for p in room["teams"][current_team] if p["socket_id"] != sid ] + # Also remove from new team if somehow already there (prevent duplicates) + room["teams"][new_team] = [ + p for p in room["teams"][new_team] if p["socket_id"] != sid + ] + # Add to new team room["teams"][new_team].append({ "name": player["name"], @@ -113,7 +222,12 @@ def register_socket_events(sio: socketio.AsyncServer): "socket_id": sid }) + # Update room state await room_manager.update_room(room_code, room) + + # Update player record with new team + await room_manager.update_player(sid, {"team": new_team}) + await sio.emit("team_changed", {"room": room}, room=room_code) @sio.event diff --git a/frontend/src/hooks/useSocket.ts b/frontend/src/hooks/useSocket.ts index c6e8bcd..bc5c03c 100644 --- a/frontend/src/hooks/useSocket.ts +++ b/frontend/src/hooks/useSocket.ts @@ -1,5 +1,5 @@ import { useEffect, useCallback } from 'react' -import { useGameStore } from '../stores/gameStore' +import { useGameStore, saveSession, clearSession } from '../stores/gameStore' import { soundPlayer } from './useSound' import { useThemeStore } from '../stores/themeStore' import { useSoundStore } from '../stores/soundStore' @@ -58,6 +58,25 @@ export function useSocket() { setRoom(data.room) }) + // Reconnection events + socket.on('rejoin_success', (data: { room: GameRoom; player_name: string; team: 'A' | 'B' }) => { + console.log('Rejoin successful:', data.player_name) + setRoom(data.room) + useGameStore.getState().setPlayerName(data.player_name) + // Update saved session with possibly new team + saveSession(data.room.code, data.player_name, data.team) + }) + + socket.on('rejoin_failed', (data: { message: string }) => { + console.log('Rejoin failed:', data.message) + clearSession() + }) + + socket.on('player_reconnected', (data: { player_name: string; team: string; room: GameRoom }) => { + console.log('Player reconnected:', data.player_name) + setRoom(data.room) + }) + // Game events socket.on('game_started', (data: { room: GameRoom }) => { setRoom(data.room) @@ -268,10 +287,19 @@ export function useSocket() { socketService.emit('timer_expired', {}) }, []) + const rejoinRoom = useCallback((roomCode: string, playerName: string, team: 'A' | 'B') => { + socketService.emit('rejoin_room', { + room_code: roomCode, + player_name: playerName, + team, + }) + }, []) + return { socket: socketService.connect(), createRoom, joinRoom, + rejoinRoom, changeTeam, startGame, selectQuestion, diff --git a/frontend/src/pages/Game.tsx b/frontend/src/pages/Game.tsx index fd7ddfb..7a0ad05 100644 --- a/frontend/src/pages/Game.tsx +++ b/frontend/src/pages/Game.tsx @@ -21,6 +21,12 @@ const allCategories: Record { if (!currentQuestion) return - if (!attempt) { + if (attempt) { + // Notify server that we're attempting to steal + stealDecision(true, currentQuestion.id) + // Keep the question modal open for the steal attempt + // The modal is already controlled by currentQuestion state + } else { + // Pass on steal stealDecision(false, currentQuestion.id) } setShowStealPrompt(false) diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index b3dd993..d1e45e1 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,8 +1,8 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { motion } from 'framer-motion' import { useSocket } from '../hooks/useSocket' -import { useGameStore } from '../stores/gameStore' +import { useGameStore, getSavedSession, saveSession, clearSession } from '../stores/gameStore' import { useThemeStore, themes } from '../stores/themeStore' import { useThemeStyles } from '../themes/ThemeProvider' import type { ThemeName } from '../types' @@ -12,16 +12,63 @@ export default function Home() { const [roomCode, setRoomCode] = useState('') const [mode, setMode] = useState<'select' | 'create' | 'join'>('select') const [error, setError] = useState('') + const [savedSession, setSavedSession] = useState>(null) + const [reconnecting, setReconnecting] = useState(false) const navigate = useNavigate() - const { createRoom, joinRoom } = useSocket() + const { createRoom, joinRoom, rejoinRoom } = useSocket() const { setPlayerName: storeSetPlayerName, room } = useGameStore() const { currentTheme, setTheme } = useThemeStore() const { config, styles } = useThemeStyles() + // Check for saved session on mount + useEffect(() => { + const session = getSavedSession() + if (session) { + setSavedSession(session) + } + }, []) + // Navigate when room is created/joined - if (room) { - navigate(`/lobby/${room.code}`) + useEffect(() => { + if (room) { + // Save session when we have a room + const currentName = useGameStore.getState().playerName + const myTeam = room.teams.A.find(p => p.name === currentName) ? 'A' : 'B' + saveSession(room.code, currentName, myTeam) + setReconnecting(false) + + // Navigate based on game status + if (room.status === 'playing') { + navigate(`/game/${room.code}`) + } else if (room.status === 'finished') { + navigate(`/results/${room.code}`) + } else { + navigate(`/lobby/${room.code}`) + } + } + }, [room, navigate]) + + const handleReconnect = () => { + if (!savedSession) return + setReconnecting(true) + storeSetPlayerName(savedSession.playerName) + rejoinRoom(savedSession.roomCode, savedSession.playerName, savedSession.team) + + // Timeout for reconnection + setTimeout(() => { + if (!room) { + setReconnecting(false) + setError('No se pudo reconectar. La sala puede haber expirado.') + clearSession() + setSavedSession(null) + } + }, 5000) + } + + const handleClearSession = () => { + clearSession() + setSavedSession(null) } const handleCreateRoom = () => { @@ -106,6 +153,50 @@ export default function Home() { : '0 4px 6px rgba(0,0,0,0.1)', }} > + {/* Reconnect Banner */} + {savedSession && mode === 'select' && ( + +

+ Partida en progreso detectada +

+

+ Sala: {savedSession.roomCode} • Jugador: {savedSession.playerName} +

+
+ + +
+
+ )} + {mode === 'select' ? (