diff --git a/server/audio_manager.py b/server/audio_manager.py index 4ac99ea..44fe985 100644 --- a/server/audio_manager.py +++ b/server/audio_manager.py @@ -32,8 +32,11 @@ else: class AudioManager: + """ + Manages all Text-to-Speech (TTS) and audio file operations, + including dynamic voice casting and file storage. + """ def __init__(self) -> None: - # FIX: Updated to use the new setting path from the reorganized config. self.output_dir = Path(settings.paths.audio_out_dir) self.output_dir.mkdir(parents=True, exist_ok=True) self.voice_cast: Dict[str, Any] = {} @@ -57,8 +60,8 @@ class AudioManager: logger.info("Dynamic Voice Casting is DISABLED.") def _load_voice_casting_sheet(self) -> None: + """Loads the voices.yml file for dynamic character voices.""" try: - # FIX: Updated to use the new setting path from the reorganized config. path = Path(settings.paths.voices_file) if not path.exists(): logger.warning(f"Voice casting file not found at '{path}'. No custom voices will be used.") @@ -70,11 +73,13 @@ class AudioManager: logger.error("Failed to load or parse voices.yml.", exc_info=True) def _initialize_rvc_instances(self) -> None: + """Initializes RVC models defined in the voice casting sheet.""" # (This method is for future RVC implementation) pass @staticmethod def _sanitize_for_tts(text: str) -> str: + """Removes markdown and other symbols from text before TTS.""" text = re.sub(r"[\*_]", "", text) text = re.sub(r"\[.*?\]", "", text) text = re.sub(r"\(.*?\)", "", text) @@ -82,6 +87,7 @@ class AudioManager: return text def _normalize_audio_chunk(self, audio_chunk: Any) -> Optional[np.ndarray]: + """Converts an audio chunk to a NumPy float32 array.""" if audio_chunk is None: return None if hasattr(audio_chunk, "detach"): try: @@ -92,6 +98,7 @@ class AudioManager: return None async def _synthesize_kokoro_stream(self, text: str, voice_name: str) -> AsyncGenerator[bytes, None]: + """Yields raw audio byte chunks from the Kokoro TTS pipeline.""" if not self.pipeline: logger.error("Kokoro pipeline not initialized. Cannot synthesize.") return @@ -104,46 +111,22 @@ class AudioManager: yield normalized_chunk.tobytes() async def _synthesize_rvc_non_stream(self, text: str, character_name: str) -> np.ndarray: - char_key = character_name.lower() + """Synthesizes audio using an RVC model (non-streaming).""" + # (This method is for future RVC implementation) + # For now, it falls back to the narrator's voice. narrator_voice = self.voice_cast.get("defaults", {}).get("narrator", settings.audio.default_voice) + audio_chunks = [chunk async for chunk in self._synthesize_kokoro_stream(text, narrator_voice)] + audio_bytes = b"".join(audio_chunks) + return np.frombuffer(audio_bytes, dtype=np.float32) if audio_bytes else np.array([], dtype=np.float32) - if char_key not in self._rvc_instances: - logger.warning(f"No RVC instance found for '{character_name}'. Falling back to narrator voice.") - audio_chunks = [] - async for chunk in self._synthesize_kokoro_stream(text, narrator_voice): - audio_chunks.append(chunk) - audio_bytes = b"".join(audio_chunks) - return np.frombuffer(audio_bytes, dtype=np.float32) if audio_bytes else np.array([], dtype=np.float32) - rvc_instance = self._rvc_instances[char_key] - voice_info = self.voice_cast.get("characters", {}).get(character_name, {}) or {} - base_voice = voice_info.get("base_voice", narrator_voice) - rvc_instance.set_voice(base_voice) - - try: - converted_audio_path_str = rvc_instance(text=text) - if converted_audio_path_str and Path(converted_audio_path_str).exists(): - converted_audio, _ = sf.read(converted_audio_path_str) - Path(converted_audio_path_str).unlink(missing_ok=True) - normalized_audio = self._normalize_audio_chunk(converted_audio) - return normalized_audio if normalized_audio is not None else np.array([], dtype=np.float32) - else: - logger.error(f"RVC conversion failed for '{character_name}'. Falling back to base voice.") - audio_chunks = [] - async for chunk in self._synthesize_kokoro_stream(text, base_voice): - audio_chunks.append(chunk) - audio_bytes = b"".join(audio_chunks) - return np.frombuffer(audio_bytes, dtype=np.float32) if audio_bytes else np.array([], dtype=np.float32) - except Exception: - logger.error(f"An exception occurred during RVC synthesis for '{character_name}'.", exc_info=True) - audio_chunks = [] - async for chunk in self._synthesize_kokoro_stream(text, base_voice): - audio_chunks.append(chunk) - audio_bytes = b"".join(audio_chunks) - return np.frombuffer(audio_bytes, dtype=np.float32) if audio_bytes else np.array([], dtype=np.float32) - - async def synthesize_stream(self, text: str, voice: Optional[str] = None) -> AsyncGenerator[bytes, None]: + async def synthesize_stream(self, text: str, room_id: Optional[str] = None, voice: Optional[str] = None) -> AsyncGenerator[bytes, None]: + """ + Synthesizes text to an audio stream, handling dynamic voices. + Note: The room_id is unused in streaming but kept for API consistency. + """ if not text.strip(): return + if not settings.audio.enable_dynamic_casting: chosen_voice = voice or settings.audio.default_voice async for chunk in self._synthesize_kokoro_stream(text, chosen_voice): @@ -167,7 +150,18 @@ class AudioManager: async for chunk in self._synthesize_kokoro_stream(segment, narrator_voice): yield chunk - async def synthesize(self, text: str, voice: Optional[str] = None) -> str: + async def synthesize(self, text: str, room_id: Optional[str] = None, voice: Optional[str] = None) -> str: + """ + Synthesizes text into a complete audio file and saves it. + + Args: + text: The text to synthesize. + room_id: The ID of the room, used to create a subdirectory for audio files. + voice: A specific voice to use, overriding dynamic casting. + + Returns: + The URL path to the generated audio file. + """ if not text.strip(): logger.warning("Synthesize called with empty text.") return "" @@ -177,15 +171,14 @@ class AudioManager: if not settings.audio.enable_dynamic_casting: chosen_voice = voice or settings.audio.default_voice audio_chunks = [chunk async for chunk in self._synthesize_kokoro_stream(text, chosen_voice)] - audio_bytes = b"".join(audio_chunks) - if audio_bytes: + if audio_bytes := b"".join(audio_chunks): full_audio_segments.append(np.frombuffer(audio_bytes, dtype=np.float32)) else: segments = re.split(r'(.*?)', text, flags=re.DOTALL) for segment in segments: sanitized_segment = self._sanitize_for_tts(segment) if not sanitized_segment: continue - + dialogue_match = re.match(r'(.*?)', segment, flags=re.DOTALL) if dialogue_match: char_name, dialogue_text = dialogue_match.groups() @@ -196,14 +189,12 @@ class AudioManager: voice_info = self.voice_cast.get("characters", {}).get(char_name, {}) or {} kokoro_voice = voice_info.get("kokoro_voice", self.voice_cast.get("defaults", {}).get("narrator", settings.audio.default_voice)) audio_chunks = [chunk async for chunk in self._synthesize_kokoro_stream(dialogue_text, kokoro_voice)] - audio_bytes = b"".join(audio_chunks) - if audio_bytes: + if audio_bytes := b"".join(audio_chunks): full_audio_segments.append(np.frombuffer(audio_bytes, dtype=np.float32)) else: narrator_voice = self.voice_cast.get("defaults", {}).get("narrator", settings.audio.default_voice) audio_chunks = [chunk async for chunk in self._synthesize_kokoro_stream(segment, narrator_voice)] - audio_bytes = b"".join(audio_chunks) - if audio_bytes: + if audio_bytes := b"".join(audio_chunks): full_audio_segments.append(np.frombuffer(audio_bytes, dtype=np.float32)) if not full_audio_segments: @@ -215,26 +206,33 @@ class AudioManager: logger.warning("TTS concatenation resulted in empty audio.") return "" + final_output_dir = self.output_dir + url_prefix = "/audio" + if room_id: + final_output_dir = self.output_dir / room_id + final_output_dir.mkdir(exist_ok=True) + url_prefix = f"/audio/{room_id}" + filename = f"{uuid.uuid4().hex}.wav" - output_path = self.output_dir / filename + output_path = final_output_dir / filename sf.write(output_path, full_audio, 24000) - url_path = f"/audio/{filename}" + url_path = f"{url_prefix}/{filename}" logger.info(f"Final audio synthesized successfully to '{url_path}'") return url_path def list_voices(self) -> Dict[str, List[str]]: + """Lists available Kokoro voices based on the casting sheet.""" try: defaults = self.voice_cast.get("defaults", {}) if settings.audio.enable_dynamic_casting else {} chars = self.voice_cast.get("characters", {}) if settings.audio.enable_dynamic_casting else {} kokoro_list = ["af_heart", "am_michael", "am_puck", "am_fenrir", "af_bella"] - narrator = defaults.get("narrator") - if narrator and narrator not in kokoro_list: - kokoro_list.append(narrator) + if narrator := defaults.get("narrator"): + if narrator not in kokoro_list: + kokoro_list.append(narrator) for v in chars.values(): - if isinstance(v, dict): - kv = v.get("kokoro_voice") - if kv and kv not in kokoro_list: + if isinstance(v, dict) and (kv := v.get("kokoro_voice")): + if kv not in kokoro_list: kokoro_list.append(kv) return {"kokoro": sorted(list(set(kokoro_list)))} except Exception: diff --git a/server/config.py b/server/config.py index 1a3e38d..b42af73 100644 --- a/server/config.py +++ b/server/config.py @@ -33,8 +33,13 @@ class AudioSettings(BaseModel): default_voice: str class MemorySettings(BaseModel): + """ + Settings for memory and database persistence. + UPDATED: Now includes separate paths for user and session databases. + """ embedding_model: str - database_file: str + sessions_db_file: str # Path to the sessions/rooms database. + users_db_file: str # Path to the users/accounts database. class PathsSettings(BaseModel): prompts_file: str diff --git a/server/database_manager.py b/server/database_manager.py index 3a30f02..6a3f1a0 100644 --- a/server/database_manager.py +++ b/server/database_manager.py @@ -2,55 +2,123 @@ import sqlite3 import json from pathlib import Path -from typing import Optional +from typing import Optional, Dict, Any from .models import Room from .logger import logger class DatabaseManager: - """Handles all direct SQLite database operations for VDM.""" + """ + Handles all direct SQLite database operations for VDM, managing separate + databases for session/room data and user/account data. + """ - def __init__(self, db_path: Path): + def __init__(self, sessions_db_path: Path, users_db_path: Path): """ - Initializes the database connection and creates tables if they don't exist. + Initializes connections to both the sessions and users databases. + It creates the databases and their respective tables if they don't exist. """ - self.db_path = db_path - self.db_path.parent.mkdir(parents=True, exist_ok=True) + self.sessions_db_path = sessions_db_path + self.users_db_path = users_db_path + self.sessions_conn: Optional[sqlite3.Connection] = None + self.users_conn: Optional[sqlite3.Connection] = None + try: - self.conn = sqlite3.connect(self.db_path, check_same_thread=False) - logger.info(f"Connected to SQLite database at '{self.db_path}'.") - self._create_tables() + # Connect to the sessions database + self.sessions_db_path.parent.mkdir(parents=True, exist_ok=True) + self.sessions_conn = sqlite3.connect(self.sessions_db_path, check_same_thread=False) + logger.info(f"Connected to sessions database at '{self.sessions_db_path}'.") + self._create_rooms_table() + + # Connect to the users database + self.users_db_path.parent.mkdir(parents=True, exist_ok=True) + self.users_conn = sqlite3.connect(self.users_db_path, check_same_thread=False) + self.users_conn.row_factory = sqlite3.Row # Use Row factory for dict-like user results + logger.info(f"Connected to users database at '{self.users_db_path}'.") + self._create_users_table() + except sqlite3.Error as e: logger.critical(f"Database connection failed: {e}", exc_info=True) raise - def _create_tables(self): - """Creates the 'rooms' table if it's not already present.""" + def _create_rooms_table(self): + """Creates the 'rooms' table in the sessions database if it's not present.""" + if not self.sessions_conn: return try: - cursor = self.conn.cursor() + cursor = self.sessions_conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS rooms ( room_id TEXT PRIMARY KEY, room_data TEXT NOT NULL ) """) - self.conn.commit() + self.sessions_conn.commit() except sqlite3.Error as e: - logger.error(f"Failed to create database tables: {e}", exc_info=True) + logger.error(f"Failed to create 'rooms' table: {e}", exc_info=True) + + def _create_users_table(self): + """Creates the 'users' table in the users database if it's not present.""" + if not self.users_conn: return + try: + cursor = self.users_conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS users ( + username_lower TEXT PRIMARY KEY, + username_cased TEXT NOT NULL UNIQUE, + hashed_password TEXT NOT NULL, + avatar_style TEXT NOT NULL + ) + """) + self.users_conn.commit() + except sqlite3.Error as e: + logger.error(f"Failed to create 'users' table: {e}", exc_info=True) + + # --- User Management Methods (Uses users_conn) --- + + def add_user(self, username: str, hashed_password: str, avatar_style: str) -> bool: + """Adds a new user to the users database.""" + if not self.users_conn: return False + try: + cursor = self.users_conn.cursor() + cursor.execute( + "INSERT INTO users (username_lower, username_cased, hashed_password, avatar_style) VALUES (?, ?, ?, ?)", + (username.lower(), username, hashed_password, avatar_style) + ) + self.users_conn.commit() + logger.info(f"Successfully added user '{username}' to the database.") + return True + except sqlite3.IntegrityError: + logger.warning(f"Attempted to add a user that already exists: {username}") + return False + except sqlite3.Error as e: + logger.error(f"Failed to add user '{username}' to database.", exc_info=True) + return False + + def get_user_by_name(self, username: str) -> Optional[Dict[str, Any]]: + """Retrieves a user's data from the users database (case-insensitive).""" + if not self.users_conn: return None + try: + cursor = self.users_conn.cursor() + cursor.execute("SELECT * FROM users WHERE username_lower = ?", (username.lower(),)) + row = cursor.fetchone() + return dict(row) if row else None + except sqlite3.Error as e: + logger.error(f"Failed to get user '{username}' from database.", exc_info=True) + return None + + # --- Room Management Methods (Uses sessions_conn) --- def save_room(self, room: Room) -> bool: - """ - Saves a room's state to the database, overwriting if it exists. - The room object is stored as a JSON string. - """ + """Saves a room's state to the sessions database.""" + if not self.sessions_conn: return False try: json_data = room.model_dump_json() - cursor = self.conn.cursor() + cursor = self.sessions_conn.cursor() cursor.execute( "INSERT OR REPLACE INTO rooms (room_id, room_data) VALUES (?, ?)", (room.room_id, json_data) ) - self.conn.commit() + self.sessions_conn.commit() logger.info(f"Successfully saved room '{room.room_id}' to the database.") return True except sqlite3.Error as e: @@ -58,14 +126,12 @@ class DatabaseManager: return False def load_room(self, room_id: str) -> Optional[Room]: - """ - Loads a room's state from the database using its ID. - """ + """Loads a room's state from the sessions database.""" + if not self.sessions_conn: return None try: - cursor = self.conn.cursor() + cursor = self.sessions_conn.cursor() cursor.execute("SELECT room_data FROM rooms WHERE room_id = ?", (room_id,)) row = cursor.fetchone() - if row: json_data = row[0] room = Room.model_validate_json(json_data) @@ -79,7 +145,10 @@ class DatabaseManager: return None def close(self): - """Closes the database connection.""" - if self.conn: - self.conn.close() - logger.info("Database connection closed.") \ No newline at end of file + """Closes both database connections.""" + if self.sessions_conn: + self.sessions_conn.close() + logger.info("Sessions database connection closed.") + if self.users_conn: + self.users_conn.close() + logger.info("Users database connection closed.") \ No newline at end of file diff --git a/server/main.py b/server/main.py index 6dca95d..1ee8397 100644 --- a/server/main.py +++ b/server/main.py @@ -11,6 +11,7 @@ from fastapi.staticfiles import StaticFiles from starlette.websockets import WebSocketState from .config import settings +from .database_manager import DatabaseManager from .models import ( Room, WSIncomingMessage, @@ -19,6 +20,7 @@ from .models import ( Player, LoginRequest, ) +from .persistence_manager import PersistenceManager from .room_manager import RoomManager from .story_manager import StoryManager from .audio_manager import AudioManager @@ -39,8 +41,6 @@ app.add_middleware( allow_headers=["*"], ) app.mount("/static", StaticFiles(directory=BASE_DIR / "web"), name="static") - -# FIX: Updated to use the new setting path from the reorganized config. app.mount("/audio", StaticFiles(directory=Path(settings.paths.audio_out_dir)), name="audio") @@ -77,8 +77,20 @@ class ConnectionManager: # --- Instantiate Managers --- -user_manager = UserManager() -room_manager = RoomManager(user_manager=user_manager) +# UPDATED: Initialize the DatabaseManager with separate paths for sessions and users. +db_manager = DatabaseManager( + sessions_db_path=Path(settings.memory.sessions_db_file), + users_db_path=Path(settings.memory.users_db_file) +) + +# Managers that depend on the database manager +persistence_manager = PersistenceManager(db_manager=db_manager) +user_manager = UserManager(db_manager=db_manager) + +# RoomManager depends on other managers +room_manager = RoomManager(user_manager=user_manager, persistence_manager=persistence_manager) + +# Standalone managers story_manager = StoryManager() audio_manager = AudioManager() game_manager = DiceRoller() @@ -89,6 +101,8 @@ connection_manager = ConnectionManager() # Core Game Loop Logic # =================================================================== +# ... (the rest of the file is unchanged) ... + async def _start_game_setup_turn(room_id: str): """ @@ -114,7 +128,7 @@ async def _start_game_setup_turn(room_id: str): room_id, WSOutgoingMessage(kind="chat_chunk", payload={"content": text_chunk}) ) - audio_generator = audio_manager.synthesize_stream(text_chunk) + audio_generator = audio_manager.synthesize_stream(text_chunk, room_id) # Pass room_id for audio path async for audio_chunk in audio_generator: encoded_chunk = base64.b64encode(audio_chunk).decode("utf-8") await connection_manager.broadcast( @@ -131,7 +145,7 @@ async def _start_game_setup_turn(room_id: str): ) else: gm_prompt = await story_manager.generate_gm_response(room_id, []) - audio_url = await audio_manager.synthesize(gm_prompt) + audio_url = await audio_manager.synthesize(gm_prompt, room_id) # Pass room_id for audio path gm_message = room_manager.add_message( room_id, "gm", "GM", gm_prompt, audio_url=audio_url ) @@ -215,7 +229,7 @@ async def _advance_turn_streaming( room_id, WSOutgoingMessage(kind="chat_chunk", payload={"content": text_chunk}) ) - audio_generator = audio_manager.synthesize_stream(text_chunk) + audio_generator = audio_manager.synthesize_stream(text_chunk, room_id) # Pass room_id for audio path async for audio_chunk in audio_generator: encoded_chunk = base64.b64encode(audio_chunk).decode("utf-8") await connection_manager.broadcast( @@ -240,7 +254,7 @@ async def _advance_turn_non_streaming( gm_response = await story_manager.generate_gm_response( room_id, history, turn_actions ) - audio_url = await audio_manager.synthesize(gm_response) + audio_url = await audio_manager.synthesize(gm_response, room_id) # Pass room_id for audio path gm_message = room_manager.add_message( room_id, "gm", "GM", gm_response, audio_url=audio_url ) @@ -275,7 +289,7 @@ async def _resume_game_turn(room_id: str, player: Player): history = [msg.model_dump() for msg in room_state.messages] gm_summary = await story_manager.generate_resume_summary(room_id, history) - audio_url = await audio_manager.synthesize(gm_summary) + audio_url = await audio_manager.synthesize(gm_summary, room_id) # Pass room_id for audio path gm_message = room_manager.add_message( room_id, "gm", "GM", gm_summary, audio_url=audio_url ) @@ -339,6 +353,15 @@ async def websocket_endpoint( await connection_manager.connect(room_id, websocket) room, player = add_player_result + # Send the existing chat history to the newly connected player + if room.messages: + await websocket.send_text( + WSOutgoingMessage( + kind="chat_history", payload={"messages": [m.model_dump() for m in room.messages]} + ).model_dump_json() + ) + + if not room.host_player_id: room.host_player_id = player_id logger.info(f"Player '{player.name}' is now the host of room '{room_id}'.") diff --git a/server/models.py b/server/models.py index 40ccac3..5fe70c6 100644 --- a/server/models.py +++ b/server/models.py @@ -8,12 +8,14 @@ from typing import Dict, List, Literal, Any, Optional # These Pydantic models define the structure of our application's state. class Player(BaseModel): + """Represents a player within a game room.""" id: str name: str avatar_style: str = "adventurer" is_active: bool = True class ChatMessage(BaseModel): + """Represents a single message in the chat history.""" author_id: str author_name: str content: str @@ -21,6 +23,7 @@ class ChatMessage(BaseModel): is_ooc: bool = False class Room(BaseModel): + """Represents the entire state of a single game room.""" room_id: str players: Dict[str, Player] = Field(default_factory=dict) messages: List[ChatMessage] = Field(default_factory=list) @@ -30,19 +33,20 @@ class Room(BaseModel): host_player_id: Optional[str] = None class RegisterRequest(BaseModel): + """Model for the /api/register endpoint payload.""" name: str avatar_style: str password: str class LoginRequest(BaseModel): + """Model for the /api/login endpoint payload.""" name: str password: str # =================================================================== # WebSocket Protocol Models # =================================================================== -# These models define the contract for messages sent between the -# server and the clients over the WebSocket connection. +# These models define the contract for messages sent over the WebSocket. class WSIncomingMessage(BaseModel): """A message received from a client.""" @@ -64,6 +68,7 @@ class WSOutgoingMessage(BaseModel): "stream_start", "chat_chunk", "audio_chunk", - "stream_end" + "stream_end", + "chat_history" ] payload: Dict[str, Any] \ No newline at end of file diff --git a/server/persistence_manager.py b/server/persistence_manager.py index a79e596..45d991e 100644 --- a/server/persistence_manager.py +++ b/server/persistence_manager.py @@ -1,8 +1,6 @@ # server/persistence_manager.py -from pathlib import Path from typing import Optional -from .config import settings from .logger import logger from .models import Room from .database_manager import DatabaseManager @@ -10,11 +8,15 @@ from .database_manager import DatabaseManager class PersistenceManager: """Handles saving and loading of room session states via the DatabaseManager.""" - def __init__(self): - """Initializes the manager and sets up the database connection.""" - db_path = Path(settings.memory.database_file) - self.db_manager = DatabaseManager(db_path) - logger.info(f"Persistence manager is now using the database backend.") + def __init__(self, db_manager: DatabaseManager): + """ + Initializes the manager with a shared database manager instance. + + Args: + db_manager: An active instance of the DatabaseManager. + """ + self.db_manager = db_manager + logger.info("Persistence manager initialized with a shared database backend.") def save_room(self, room: Room) -> bool: """ @@ -37,7 +39,7 @@ class PersistenceManager: room_id: The ID of the room to load. Returns: - A Room object if a session was found and loaded successfully, otherwise None. + A Room object if a session was found and loaded, otherwise None. """ logger.info(f"Attempting to load session for room '{room_id}'...") return self.db_manager.load_room(room_id) \ No newline at end of file diff --git a/server/room_manager.py b/server/room_manager.py index 5718e41..dc7d5b0 100644 --- a/server/room_manager.py +++ b/server/room_manager.py @@ -3,29 +3,41 @@ from typing import Dict, Optional, Tuple from .models import Room, Player, ChatMessage from .logger import logger -# REMOVED: from .persistence_manager import PersistenceManager from .user_manager import UserManager +from .persistence_manager import PersistenceManager class RoomManager: """Manages the state of all active VDM rooms in memory.""" - def __init__(self, user_manager: UserManager): - """Initializes the RoomManager with its dependencies.""" - from .persistence_manager import PersistenceManager # <-- FIX: Import moved inside __init__ - + def __init__(self, user_manager: UserManager, persistence_manager: PersistenceManager): + """ + Initializes the RoomManager with its dependencies. + + Args: + user_manager: An active instance of the UserManager. + persistence_manager: An active instance of the PersistenceManager. + """ self._rooms: Dict[str, Room] = {} - self._persistence_manager = PersistenceManager() + self._persistence_manager = persistence_manager self._user_manager = user_manager def get_or_create_room(self, room_id: str) -> Room: """ - Retrieves a room by its ID, loading from a session file if available. + Retrieves a room by its ID from memory, or loads it from persistence. + If not found, a new empty room is created. + + Args: + room_id: The unique identifier for the room. + + Returns: + The active Room object. """ if room_id in self._rooms: return self._rooms[room_id] loaded_room = self._persistence_manager.load_room(room_id) if loaded_room: + # When loading a room, mark all players as inactive until they reconnect. for player in loaded_room.players.values(): player.is_active = False self._rooms[room_id] = loaded_room @@ -37,7 +49,7 @@ class RoomManager: return new_room def save_room_state(self, room_id: str): - """A convenience method to trigger saving a room's state.""" + """A convenience method to trigger saving a room's state via the persistence manager.""" if room_id in self._rooms: self._persistence_manager.save_room(self._rooms[room_id]) else: @@ -45,7 +57,16 @@ class RoomManager: def add_player(self, room_id: str, player_id: str, player_token: str) -> Optional[Tuple[Room, Player]]: """ - Adds or reactivates a player in a room using their auth token. + Adds a player to a room or reactivates them if they are rejoining. + Validates the player's session token. + + Args: + room_id: The ID of the room to join. + player_id: The client-generated unique ID for the player's connection. + player_token: The session token obtained during login. + + Returns: + A tuple of (Room, Player) if successful, otherwise None. """ player_data = self._user_manager.get_user_by_token(player_token) if not player_data: @@ -53,38 +74,27 @@ class RoomManager: return None room = self.get_or_create_room(room_id) - player_name = player_data["name"] + player_name = player_data["username_cased"] # Use the cased name from DB + # Check if this player (by name) is already in the room state existing_player: Optional[Player] = None - old_player_id: Optional[str] = None - for pid, p in room.players.items(): + for p in room.players.values(): if p.name.lower() == player_name.lower(): existing_player = p - old_player_id = pid break - + if existing_player: - if existing_player.is_active: - logger.warning(f"Player '{player_name}' tried to join room '{room_id}' but is already active.") - # Allow rejoining for simplicity, just update their ID. - # This handles cases where a client disconnects without the server knowing. - logger.info(f"Player '{player_name}' is reconnecting to room '{room_id}'.") - if old_player_id and old_player_id in room.players: - # Remove the old entry if the client ID has changed (e.g., new browser tab) - if old_player_id != player_id: - del room.players[old_player_id] - - existing_player.id = player_id existing_player.is_active = True + # Update ID and avatar in case they changed or are logging in from a new client + existing_player.id = player_id existing_player.avatar_style = player_data["avatar_style"] - room.players[player_id] = existing_player return room, existing_player logger.info(f"Player '{player_name}' ({player_id}) joined room '{room_id}' for the first time.") new_player = Player( id=player_id, - name=player_data["name"], + name=player_name, avatar_style=player_data["avatar_style"], is_active=True ) @@ -93,13 +103,23 @@ class RoomManager: def remove_player(self, room_id: str, player_id: str) -> Optional[Player]: """ - Deactivates a player in a room, preserving their data. + Deactivates a player in a room, preserving their data for reconnection. + If the last active player leaves, the room state is saved. + + Args: + room_id: The ID of the room the player is leaving. + player_id: The ID of the player's connection. + + Returns: + The Player object that was deactivated, or None if not found. """ room = self._rooms.get(room_id) if room and player_id in room.players: player = room.players[player_id] player.is_active = False logger.info(f"Player '{player.name}' ({player_id}) disconnected from room '{room_id}'.") + + # If this was the last active player, save the game state. if not any(p.is_active for p in room.players.values()): logger.info(f"Last active player left room '{room_id}'. Saving state.") self.save_room_state(room_id) @@ -115,7 +135,10 @@ class RoomManager: audio_url: Optional[str] = None ) -> ChatMessage: """ - Adds a chat message to a room's history, optionally with an audio URL. + Adds a chat message to a room's history. + + Returns: + The created ChatMessage object. """ room = self.get_or_create_room(room_id) message = ChatMessage( diff --git a/server/user_manager.py b/server/user_manager.py index f4cef65..7bab061 100644 --- a/server/user_manager.py +++ b/server/user_manager.py @@ -1,150 +1,140 @@ # server/user_manager.py -import json import uuid -from pathlib import Path -from typing import Dict, Optional, Tuple, TypedDict +from typing import Dict, Optional, Tuple, TypedDict, Any from passlib.context import CryptContext -from .config import settings from .logger import logger +from .database_manager import DatabaseManager +# These TypedDicts define the shape of user data for different contexts. class StoredUserData(TypedDict): - name: str + """Represents the data shape of a user record from the database.""" + username_cased: str avatar_style: str hashed_password: str class ClientUserData(TypedDict): + """Represents the user data sent to the client upon successful login.""" token: str name: str avatar_style: str +# CryptContext for hashing and verifying user passwords securely. pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - class UserManager: """ - Manages player registration and authentication using a simple JSON file. + Manages player registration and authentication using the SQLite database. - Stores users with hashed passwords for persistence. - - Issues temporary session tokens upon successful login. + - Issues temporary in-memory session tokens upon successful login. """ - def __init__(self): - """Initializes the UserManager and loads user data.""" - # FIX: Updated to use the new setting path from the reorganized config. - self.users_file = Path(settings.paths.memory_dir) / "users.json" - - self._users_by_name: Dict[str, StoredUserData] = {} + def __init__(self, db_manager: DatabaseManager): + """ + Initializes the UserManager with a database manager instance. + + Args: + db_manager: An active instance of the DatabaseManager. + """ + self.db = db_manager + # Sessions are kept in memory. A server restart will log everyone out. self._sessions: Dict[str, str] = {} - - self._load_users() - - def _load_users(self): - """Loads the users.json file into memory.""" - try: - if self.users_file.exists(): - with open(self.users_file, "r", encoding="utf-8") as f: - loaded_data: Dict[str, Dict[str, str]] = json.load(f) - - for name, data in loaded_data.items(): - if "avatar_style" in data and "hashed_password" in data: - user: StoredUserData = { - "name": name, - "avatar_style": data["avatar_style"], - "hashed_password": data["hashed_password"] - } - self._users_by_name[name.lower()] = user - else: - logger.warning(f"Skipping malformed user entry for '{name}' in users.json.") - - logger.info(f"Loaded {len(self._users_by_name)} users from {self.users_file}") - else: - logger.info("No users.json file found. A new one will be created upon registration.") - except Exception: - logger.error(f"Failed to load or parse {self.users_file}.", exc_info=True) - - def _save_users(self): - """Saves the current user data to users.json.""" - try: - data_to_save = { - user["name"]: { - "avatar_style": user["avatar_style"], - "hashed_password": user["hashed_password"] - } - for user in self._users_by_name.values() - } - with open(self.users_file, "w", encoding="utf-8") as f: - json.dump(data_to_save, f, indent=2) - except Exception: - logger.error(f"Failed to save users to {self.users_file}.", exc_info=True) + logger.info("UserManager initialized with database backend.") def _get_password_hash(self, password: str) -> str: + """Hashes a plain-text password.""" return pwd_context.hash(password) def _verify_password(self, plain_password: str, hashed_password: str) -> bool: + """Verifies a plain-text password against a stored hash.""" return pwd_context.verify(plain_password, hashed_password) def register_player(self, name: str, avatar_style: str, password: str) -> Tuple[bool, str]: """ - Registers a new player name, avatar style, and password. - Returns a tuple of (success, message). + Registers a new player in the database. + + Args: + name: The desired username. + avatar_style: The chosen avatar style. + password: The plain-text password. + + Returns: + A tuple containing a success boolean and a status message. """ if not (3 <= len(name) <= 20): return False, "Player name must be between 3 and 20 characters." if len(password) < 8: return False, "Password must be at least 8 characters long." - if name.lower() in self._users_by_name: + # Check if user already exists in the database + if self.db.get_user_by_name(name): return False, "Player name is already registered." hashed_password = self._get_password_hash(password) - - new_user: StoredUserData = { - "name": name, - "avatar_style": avatar_style, - "hashed_password": hashed_password, - } - - self._users_by_name[name.lower()] = new_user - self._save_users() - - logger.info(f"Registered new player '{name}'.") - return True, "Registration successful. You can now log in." + success = self.db.add_user(name, hashed_password, avatar_style) + + if success: + logger.info(f"Registered new player '{name}'.") + return True, "Registration successful. You can now log in." + else: + return False, "An unexpected error occurred during registration." def login(self, name: str, password: str) -> Optional[ClientUserData]: """ - Verifies a user's credentials. If successful, creates a new session - token and returns the client-safe user data. + Verifies user credentials against the database and creates a session token. + + Args: + name: The username to log in with. + password: The plain-text password. + + Returns: + A dictionary with client-safe user data if successful, otherwise None. """ - user = self._users_by_name.get(name.lower()) - if not user: + user_data = self.db.get_user_by_name(name) + if not user_data: return None - - if not self._verify_password(password, user["hashed_password"]): + + if not self._verify_password(password, user_data["hashed_password"]): return None - + session_token = str(uuid.uuid4()) - self._sessions[session_token] = user["name"] - - logger.info(f"Player '{user['name']}' logged in successfully. Session token created.") - + # Store the correctly-cased username in the session map + self._sessions[session_token] = user_data["username_cased"] + logger.info(f"Player '{user_data['username_cased']}' logged in successfully.") + client_data: ClientUserData = { - "name": user["name"], - "avatar_style": user["avatar_style"], + "name": user_data["username_cased"], + "avatar_style": user_data["avatar_style"], "token": session_token } return client_data - def get_user_by_token(self, token: str) -> Optional[StoredUserData]: - """Finds a user's data using their active session token.""" + def get_user_by_token(self, token: str) -> Optional[Dict[str, Any]]: + """ + Finds a user's full data from the database using their session token. + + Args: + token: The user's active session token. + + Returns: + A dictionary with the user's database record if the token is valid, + otherwise None. + """ username = self._sessions.get(token) if not username: return None - - return self._users_by_name.get(username.lower()) - + + # Fetch fresh user data from the database + return self.db.get_user_by_name(username) + def logout(self, token: str): - """Removes a session token, effectively logging the user out.""" + """ + Removes a session token, effectively logging the user out. + + Args: + token: The session token to invalidate. + """ if token in self._sessions: del self._sessions[token] logger.info(f"Session token {token[:8]}... ended.") \ No newline at end of file diff --git a/settings.yml b/settings.yml index 19a0340..e11553b 100644 --- a/settings.yml +++ b/settings.yml @@ -64,9 +64,11 @@ memory: # long-term memory (RAG). This runs locally. embedding_model: "sentence-transformers/all-MiniLM-L6-v2" - # The file path for the SQLite database where all game sessions, players, - # and messages are saved. - database_file: "./memory/vdm_sessions.db" + # UPDATED: Split database paths for better organization. + # The database for saving game room/session states. + sessions_db_file: "./database/vdm_sessions.db" + # The database for saving user accounts and profiles. + users_db_file: "./database/vdm_users.db" # === SECTION: Resource Paths ================================================== @@ -75,7 +77,7 @@ memory: paths: # Path to the YAML file containing all AI system prompts. prompts_file: "./prompts.yml" - + # Path to the YAML file that maps character names to specific voices for # the Dynamic Voice Casting feature. voices_file: "./voices.yml" diff --git a/web/index.html b/web/index.html index 1415f87..0e5cfce 100644 --- a/web/index.html +++ b/web/index.html @@ -15,7 +15,7 @@
VDM
- +
@@ -72,7 +72,7 @@ Player Avatar - +