feat(phase6): Add sounds, team chat, reactions, monitor, settings, and CSV import/export

Sound System:
- Add soundStore with volume/mute persistence
- Add useSound hook with Web Audio API fallback
- Add SoundControl component for in-game volume adjustment
- Play sounds for correct/incorrect, steal, timer, victory/defeat

Team Chat:
- Add TeamChat component with collapsible panel
- Add team_message WebSocket event (team-only visibility)
- Store up to 50 messages per session

Emoji Reactions:
- Add EmojiReactions bar with 8 emojis
- Add ReactionOverlay with floating animations (Framer Motion)
- Add rate limiting (1 reaction per 3 seconds)
- Broadcast reactions to all players in room

Admin Monitor:
- Add Monitor page showing active rooms from Redis
- Display player counts, team composition, status
- Add ability to close problematic rooms

Admin Settings:
- Add Settings page for game configuration
- Configure points/times by difficulty, steal penalty, max players
- Store config in JSON file with service helpers

CSV Import/Export:
- Add export endpoint with optional filters
- Add import endpoint with validation and error reporting
- Add UI buttons and import result modal in Questions page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 08:58:33 +00:00
parent 90fa220890
commit 720432702f
23 changed files with 2753 additions and 51 deletions

View File

@@ -1,11 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from typing import List
import csv
import json
from io import StringIO
from app.models.base import get_db
from app.models.admin import Admin
@@ -17,6 +21,9 @@ from app.schemas.question import (
AIGenerateRequest
)
from app.services.ai_generator import ai_generator
from app.services.room_manager import room_manager
from app.services.game_config import get_game_settings, update_game_settings
from app.schemas.game_config import GameSettingsSchema
from app.config import get_settings
router = APIRouter()
@@ -294,3 +301,348 @@ async def create_category(
await db.commit()
await db.refresh(category)
return category
# CSV Import/Export
@router.get("/questions/export")
async def export_questions(
category_id: int = None,
status: str = None,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
"""
Export questions to CSV format.
Query params: category_id (optional), status (optional)
Returns CSV file as download.
"""
# Build query with filters
query = select(Question, Category.name.label("category_name")).join(
Category, Question.category_id == Category.id
)
if category_id:
query = query.where(Question.category_id == category_id)
if status:
query = query.where(Question.status == status)
result = await db.execute(query.order_by(Question.created_at.desc()))
rows = result.all()
# Create CSV in memory
output = StringIO()
writer = csv.writer(output)
# Write header
writer.writerow([
"category", "question", "correct_answer", "alt_answers",
"difficulty", "fun_fact", "status", "date_active"
])
# Write data rows
for row in rows:
question = row[0]
category_name = row[1]
# Join alt_answers with pipe separator
alt_answers_str = "|".join(question.alt_answers) if question.alt_answers else ""
# Format date_active
date_active_str = question.date_active.isoformat() if question.date_active else ""
writer.writerow([
category_name,
question.question_text,
question.correct_answer,
alt_answers_str,
question.difficulty,
question.fun_fact or "",
question.status,
date_active_str
])
# Prepare response
output.seek(0)
# Generate filename with timestamp
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
filename = f"questions_export_{timestamp}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Type": "text/csv; charset=utf-8"
}
)
@router.post("/questions/import")
async def import_questions(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
"""
Import questions from CSV file.
Expected columns: category, question, correct_answer, alt_answers, difficulty, fun_fact
alt_answers should be separated by pipe |
Returns: {imported: count, errors: [{row, error}]}
"""
# Validate file type
if not file.filename.endswith('.csv'):
raise HTTPException(
status_code=400,
detail="File must be a CSV"
)
# Read file content
try:
content = await file.read()
decoded_content = content.decode('utf-8')
except UnicodeDecodeError:
# Try with latin-1 encoding as fallback
try:
decoded_content = content.decode('latin-1')
except:
raise HTTPException(
status_code=400,
detail="Could not decode file. Please use UTF-8 encoding."
)
# Parse CSV
csv_reader = csv.DictReader(StringIO(decoded_content))
# Required columns
required_columns = {"category", "question", "correct_answer", "difficulty"}
# Validate headers
if not csv_reader.fieldnames:
raise HTTPException(
status_code=400,
detail="CSV file is empty or has no headers"
)
headers = set(csv_reader.fieldnames)
missing_columns = required_columns - headers
if missing_columns:
raise HTTPException(
status_code=400,
detail=f"Missing required columns: {', '.join(missing_columns)}"
)
# Get all categories for lookup
categories_result = await db.execute(select(Category))
categories = {cat.name.lower(): cat.id for cat in categories_result.scalars().all()}
imported_count = 0
errors = []
for row_num, row in enumerate(csv_reader, start=2): # Start at 2 (1 is header)
try:
# Get category
category_name = row.get("category", "").strip()
if not category_name:
errors.append({"row": row_num, "error": "Category is required"})
continue
category_id = categories.get(category_name.lower())
if not category_id:
errors.append({"row": row_num, "error": f"Category '{category_name}' not found"})
continue
# Get question text
question_text = row.get("question", "").strip()
if not question_text:
errors.append({"row": row_num, "error": "Question text is required"})
continue
# Get correct answer
correct_answer = row.get("correct_answer", "").strip()
if not correct_answer:
errors.append({"row": row_num, "error": "Correct answer is required"})
continue
# Get difficulty
try:
difficulty = int(row.get("difficulty", "3"))
if difficulty < 1 or difficulty > 5:
errors.append({"row": row_num, "error": "Difficulty must be between 1 and 5"})
continue
except ValueError:
errors.append({"row": row_num, "error": "Difficulty must be a number"})
continue
# Parse alt_answers (pipe separated)
alt_answers_str = row.get("alt_answers", "").strip()
alt_answers = [a.strip() for a in alt_answers_str.split("|") if a.strip()] if alt_answers_str else []
# Get fun_fact (optional)
fun_fact = row.get("fun_fact", "").strip() or None
# Calculate points and time based on difficulty
points = settings.default_points.get(difficulty, 300)
time_seconds = settings.default_times.get(difficulty, 25)
# Create question with pending status
question = Question(
category_id=category_id,
question_text=question_text,
correct_answer=correct_answer,
alt_answers=alt_answers,
difficulty=difficulty,
points=points,
time_seconds=time_seconds,
fun_fact=fun_fact,
status="pending"
)
db.add(question)
imported_count += 1
except Exception as e:
errors.append({"row": row_num, "error": str(e)})
# Commit all valid questions
if imported_count > 0:
await db.commit()
return {
"imported": imported_count,
"errors": errors
}
# Game Settings
@router.get("/settings")
async def get_settings_endpoint(
admin: Admin = Depends(get_current_admin)
):
"""
Get current game settings.
Returns configuration for points, times, steal mechanics, and team limits.
"""
return get_game_settings()
@router.put("/settings")
async def update_settings_endpoint(
settings_data: GameSettingsSchema,
admin: Admin = Depends(get_current_admin)
):
"""
Update game settings.
Expects a complete settings object with all fields.
"""
return update_game_settings(settings_data)
# Room Monitor
async def get_active_rooms_from_redis() -> List[dict]:
"""
Helper function to scan and retrieve all active rooms from Redis.
Returns list of room summaries with player counts and team info.
"""
await room_manager.connect()
rooms = []
cursor = 0
# Scan for all room:* keys
while True:
cursor, keys = await room_manager.redis.scan(cursor, match="room:*", count=100)
for key in keys:
try:
data = await room_manager.redis.get(key)
if data:
room_data = json.loads(data)
# Count players per team
team_a_count = len(room_data.get("teams", {}).get("A", []))
team_b_count = len(room_data.get("teams", {}).get("B", []))
total_players = team_a_count + team_b_count
# Get TTL for time remaining
ttl = await room_manager.redis.ttl(key)
rooms.append({
"room_code": room_data.get("code", ""),
"players_count": total_players,
"teams": {
"A": team_a_count,
"B": team_b_count
},
"status": room_data.get("status", "unknown"),
"host": room_data.get("host", ""),
"ttl_seconds": ttl if ttl > 0 else 0,
"scores": room_data.get("scores", {"A": 0, "B": 0})
})
except (json.JSONDecodeError, Exception):
# Skip malformed room data
continue
if cursor == 0:
break
return rooms
@router.get("/rooms/active")
async def get_active_rooms(
admin: Admin = Depends(get_current_admin)
):
"""
Get list of all active game rooms from Redis.
Returns: list of {room_code, players_count, teams: {A: count, B: count}, status, host, ttl_seconds}
"""
rooms = await get_active_rooms_from_redis()
return {"rooms": rooms, "total": len(rooms)}
@router.delete("/rooms/{room_code}")
async def close_room(
room_code: str,
admin: Admin = Depends(get_current_admin)
):
"""
Close a room by removing it from Redis.
Also removes all player mappings associated with the room.
Socket notifications should be handled by the caller through the socket server.
"""
await room_manager.connect()
# Check if room exists
room_data = await room_manager.get_room(room_code)
if not room_data:
raise HTTPException(status_code=404, detail="Room not found")
# Get all player socket IDs to clean up player mappings
player_sockets = []
for team in ["A", "B"]:
for player in room_data.get("teams", {}).get(team, []):
socket_id = player.get("socket_id")
if socket_id:
player_sockets.append(socket_id)
# Delete player mappings
for socket_id in player_sockets:
await room_manager.redis.delete(f"player:{socket_id}")
# Delete room stats for all players
for team in ["A", "B"]:
for player in room_data.get("teams", {}).get(team, []):
player_name = player.get("name")
if player_name:
await room_manager.redis.delete(f"stats:{room_code}:{player_name}")
# Delete the room itself
await room_manager.redis.delete(f"room:{room_code}")
return {
"status": "closed",
"room_code": room_code,
"players_affected": len(player_sockets)
}

View File

@@ -0,0 +1,19 @@
{
"points_by_difficulty": {
"1": 100,
"2": 200,
"3": 300,
"4": 400,
"5": 500
},
"times_by_difficulty": {
"1": 15,
"2": 20,
"3": 25,
"4": 35,
"5": 45
},
"steal_penalty_percent": 50,
"max_players_per_team": 4,
"steal_time_percent": 50
}

View File

@@ -0,0 +1,22 @@
from pydantic import BaseModel
from typing import Dict
class GameSettingsSchema(BaseModel):
"""Schema for game configuration settings."""
points_by_difficulty: Dict[str, int]
times_by_difficulty: Dict[str, int]
steal_penalty_percent: int
max_players_per_team: int
steal_time_percent: int
class Config:
json_schema_extra = {
"example": {
"points_by_difficulty": {"1": 100, "2": 200, "3": 300, "4": 400, "5": 500},
"times_by_difficulty": {"1": 15, "2": 20, "3": 25, "4": 35, "5": 45},
"steal_penalty_percent": 50,
"max_players_per_team": 4,
"steal_time_percent": 50
}
}

View File

@@ -0,0 +1,78 @@
"""Service for managing dynamic game configuration."""
import json
import os
from pathlib import Path
from typing import Dict, Any
from app.schemas.game_config import GameSettingsSchema
# Path to the config file
CONFIG_FILE = Path(__file__).parent.parent / "data" / "config.json"
# Default configuration
DEFAULT_CONFIG: Dict[str, Any] = {
"points_by_difficulty": {"1": 100, "2": 200, "3": 300, "4": 400, "5": 500},
"times_by_difficulty": {"1": 15, "2": 20, "3": 25, "4": 35, "5": 45},
"steal_penalty_percent": 50,
"max_players_per_team": 4,
"steal_time_percent": 50,
}
def _ensure_config_file():
"""Ensure the config file exists with default values."""
if not CONFIG_FILE.exists():
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(CONFIG_FILE, "w") as f:
json.dump(DEFAULT_CONFIG, f, indent=2)
def get_game_settings() -> Dict[str, Any]:
"""Load game settings from JSON file."""
_ensure_config_file()
try:
with open(CONFIG_FILE, "r") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
# Return defaults if file is corrupted
return DEFAULT_CONFIG.copy()
def update_game_settings(settings: GameSettingsSchema) -> Dict[str, Any]:
"""Update game settings in JSON file."""
_ensure_config_file()
config = settings.model_dump()
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=2)
return config
def get_points_for_difficulty(difficulty: int) -> int:
"""Get points for a specific difficulty level."""
settings = get_game_settings()
return settings["points_by_difficulty"].get(str(difficulty), 300)
def get_time_for_difficulty(difficulty: int) -> int:
"""Get time (in seconds) for a specific difficulty level."""
settings = get_game_settings()
return settings["times_by_difficulty"].get(str(difficulty), 25)
def get_steal_penalty_multiplier() -> float:
"""Get steal penalty as a multiplier (0-1)."""
settings = get_game_settings()
return settings["steal_penalty_percent"] / 100.0
def get_steal_time_multiplier() -> float:
"""Get steal time as a multiplier (0-1)."""
settings = get_game_settings()
return settings["steal_time_percent"] / 100.0
def get_max_players_per_team() -> int:
"""Get maximum players allowed per team."""
settings = get_game_settings()
return settings["max_players_per_team"]

View File

@@ -1,4 +1,5 @@
import socketio
import time
from datetime import datetime
from app.services.room_manager import room_manager
from app.services.game_manager import game_manager
@@ -8,6 +9,11 @@ from app.schemas.achievement import PlayerStats
from app.models.base import get_async_session
# Rate limiting para reacciones: {room_code: {player_name: last_reaction_timestamp}}
reaction_rate_limits: dict[str, dict[str, float]] = {}
REACTION_COOLDOWN_SECONDS = 3
async def get_db_session():
"""Helper para obtener sesion de BD en contexto de socket."""
AsyncSessionLocal = get_async_session()
@@ -353,13 +359,56 @@ def register_socket_events(sio: socketio.AsyncServer):
)
@sio.event
async def emoji_reaction(sid, data):
"""Send an emoji reaction visible to all."""
async def team_message(sid, data):
"""Send a team chat message - only visible to teammates."""
room_code = data.get("room_code", "")
team = data.get("team", "")
player_name = data.get("player_name", "")
message = data.get("message", "")[:500] # Limit message length
if not all([room_code, team, player_name, message]):
return
# Validate player exists in room
player = await room_manager.get_player(sid)
if not player or player["room"] != room_code or player["team"] != team:
return
# Get room data to find team members
room = await room_manager.get_room(room_code)
if not room:
return
# Get socket IDs of team members
team_sockets = [
p["socket_id"] for p in room["teams"][team]
if p.get("socket_id")
]
# Emit only to team members
message_data = {
"player_name": player_name,
"team": team,
"message": message,
"timestamp": datetime.utcnow().isoformat()
}
for socket_id in team_sockets:
await sio.emit(
"receive_team_message",
message_data,
to=socket_id
)
@sio.event
async def send_reaction(sid, data):
"""Send an emoji reaction visible to all players in the room."""
player = await room_manager.get_player(sid)
if not player:
return
room_code = player["room"]
room_code = data.get("room_code", player["room"])
player_name = data.get("player_name", player["name"])
emoji = data.get("emoji", "")
# Validate emoji
@@ -367,16 +416,37 @@ def register_socket_events(sio: socketio.AsyncServer):
if emoji not in allowed_emojis:
return
# Rate limiting: max 1 reaction every 3 seconds per player
current_time = time.time()
if room_code not in reaction_rate_limits:
reaction_rate_limits[room_code] = {}
last_reaction = reaction_rate_limits[room_code].get(player_name, 0)
if current_time - last_reaction < REACTION_COOLDOWN_SECONDS:
# Player is rate limited, ignore the reaction
return
# Update last reaction time
reaction_rate_limits[room_code][player_name] = current_time
# Emit to ALL players in the room (both teams)
await sio.emit(
"emoji_reaction",
"receive_reaction",
{
"player_name": player["name"],
"player_name": player_name,
"team": player["team"],
"emoji": emoji
"emoji": emoji,
"timestamp": datetime.utcnow().isoformat()
},
room=room_code
)
# Keep old event name for backwards compatibility
@sio.event
async def emoji_reaction(sid, data):
"""Alias for send_reaction (backwards compatibility)."""
await send_reaction(sid, data)
@sio.event
async def timer_expired(sid, data):
"""Handle timer expiration."""