feat: reconexión de sesión + 6 nuevas categorías + corrección de bugs

- Añade sistema de reconexión tras refresh/cierre del navegador
  - Persistencia de sesión en localStorage (3h TTL)
  - Banner de reconexión en Home
  - Evento rejoin_room en backend

- Nuevas categorías: Series TV, Marvel/DC, Disney, Memes, Pokémon, Mitología

- Correcciones de bugs:
  - Fix: juego bloqueado al fallar robo (steal decision)
  - Fix: jugador duplicado al cambiar de equipo
  - Fix: rotación incorrecta de turno tras fallo

- Config: soporte para Cloudflare tunnel (allowedHosts)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-27 01:53:32 +00:00
parent 6248037b47
commit 112f489e40
9 changed files with 327 additions and 12 deletions

View File

@@ -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(

View File

@@ -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()

View File

@@ -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

View File

@@ -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,

View File

@@ -21,6 +21,12 @@ const allCategories: Record<number, { name: string; icon: string; color: string
6: { name: 'Películas', icon: '🎬', color: '#F5C518' },
7: { name: 'Libros', icon: '📚', color: '#8B4513' },
8: { name: 'Historia', icon: '🏛️', color: '#6B5B95' },
9: { name: 'Series de TV', icon: '📺', color: '#E50914' },
10: { name: 'Marvel/DC', icon: '🦸', color: '#ED1D24' },
11: { name: 'Disney', icon: '🏰', color: '#113CCF' },
12: { name: 'Memes', icon: '🐸', color: '#7CFC00' },
13: { name: 'Pokémon', icon: '🔴', color: '#FFCB05' },
14: { name: 'Mitología', icon: '⚡', color: '#9B59B6' },
}
export default function Game() {
@@ -116,7 +122,13 @@ export default function Game() {
const handleStealDecision = (attempt: boolean) => {
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)

View File

@@ -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,17 +12,64 @@ export default function Home() {
const [roomCode, setRoomCode] = useState('')
const [mode, setMode] = useState<'select' | 'create' | 'join'>('select')
const [error, setError] = useState('')
const [savedSession, setSavedSession] = useState<ReturnType<typeof getSavedSession>>(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
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 = () => {
if (!playerName.trim()) {
@@ -106,6 +153,50 @@ export default function Home() {
: '0 4px 6px rgba(0,0,0,0.1)',
}}
>
{/* Reconnect Banner */}
{savedSession && mode === 'select' && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-4 p-4 rounded-lg"
style={{
backgroundColor: config.colors.accent + '20',
border: `1px solid ${config.colors.accent}`,
}}
>
<p className="text-sm mb-2" style={{ color: config.colors.text }}>
Partida en progreso detectada
</p>
<p className="text-xs mb-3" style={{ color: config.colors.textMuted }}>
Sala: <strong>{savedSession.roomCode}</strong> Jugador: <strong>{savedSession.playerName}</strong>
</p>
<div className="flex gap-2">
<button
onClick={handleReconnect}
disabled={reconnecting}
className="flex-1 py-2 rounded-lg font-bold text-sm transition-all hover:scale-105 disabled:opacity-50"
style={{
backgroundColor: config.colors.accent,
color: '#FFF',
}}
>
{reconnecting ? 'Reconectando...' : 'Reconectar'}
</button>
<button
onClick={handleClearSession}
className="px-3 py-2 rounded-lg text-sm transition-all hover:scale-105"
style={{
backgroundColor: 'transparent',
color: config.colors.textMuted,
border: `1px solid ${config.colors.textMuted}`,
}}
>
Ignorar
</button>
</div>
</motion.div>
)}
{mode === 'select' ? (
<div className="space-y-4">
<button

View File

@@ -10,11 +10,13 @@ class SocketService {
if (!this.socket) {
console.log('Creating new socket connection to:', SOCKET_URL)
this.socket = io(SOCKET_URL, {
transports: ['websocket', 'polling'],
transports: ['polling', 'websocket'],
autoConnect: true,
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 1000,
upgrade: true,
rememberUpgrade: true,
})
this.socket.on('connect', () => {

View File

@@ -1,6 +1,48 @@
import { create } from 'zustand'
import type { GameRoom, Question, ChatMessage, Achievement } from '../types'
// Session persistence helpers
const SESSION_KEY = 'trivy_session'
interface SavedSession {
roomCode: string
playerName: string
team: 'A' | 'B'
timestamp: number
}
export function saveSession(roomCode: string, playerName: string, team: 'A' | 'B') {
const session: SavedSession = {
roomCode,
playerName,
team,
timestamp: Date.now()
}
localStorage.setItem(SESSION_KEY, JSON.stringify(session))
}
export function getSavedSession(): SavedSession | null {
try {
const data = localStorage.getItem(SESSION_KEY)
if (!data) return null
const session: SavedSession = JSON.parse(data)
// Session expires after 3 hours (same as room TTL)
const threeHours = 3 * 60 * 60 * 1000
if (Date.now() - session.timestamp > threeHours) {
clearSession()
return null
}
return session
} catch {
return null
}
}
export function clearSession() {
localStorage.removeItem(SESSION_KEY)
}
export interface Reaction {
id: string
player_name: string

View File

@@ -5,7 +5,8 @@ export default defineConfig({
plugins: [react()],
server: {
port: 3000,
host: true
host: true,
allowedHosts: ['trivy.consultoria-as.com', 'localhost', '192.168.10.217']
},
build: {
outDir: 'dist',