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 @@
-
+
Join a Game
diff --git a/web/js/api.js b/web/js/api.js
index 8c6703f..84ea446 100644
--- a/web/js/api.js
+++ b/web/js/api.js
@@ -33,6 +33,10 @@ export function initApi(state, ui) {
case 'chat': // Non-streamed complete message
ui.logMessage('chat', msg.payload);
break;
+ // NEW: Handle the batch of historical messages upon joining.
+ case 'chat_history':
+ ui.loadChatHistory(msg.payload.messages);
+ break;
case 'audio': // Non-streamed complete audio file
ui.playAudioFile(msg.payload.url);
break;
diff --git a/web/js/ui.js b/web/js/ui.js
index be07799..794890b 100644
--- a/web/js/ui.js
+++ b/web/js/ui.js
@@ -9,7 +9,8 @@ import { initAudio } from './audio.js';
/**
* @typedef {object} AppUI - The public interface of the UI module.
* @property {(api: ReturnType) => void} setApi
- * @property {(type: string, data: any) => void} logMessage
+ * @property {(type: string, data: any, isBatch: boolean) => void} logMessage
+ * @property {(messages: any[]) => void} loadChatHistory
* @property {(room: any) => void} updateRoomState
* @property {(url: string) => void} playAudioFile
* @property {() => void} handleStreamStart
@@ -23,11 +24,21 @@ import { initAudio } from './audio.js';
// List of available avatar styles.
const AVATAR_STYLES = [
- "adventurer", "adventurer-neutral", "avataaars", "big-ears", "big-smile",
- "bottts", "croodles", "fun-emoji", "icons", "identicon", "initials",
+ "adventurer", "adventurer-neutral", "avataaars", "big-ears", "big-smile",
+ "bottts", "croodles", "fun-emoji", "icons", "identicon", "initials",
"lorelei", "micah", "miniavs", "open-peeps", "personas", "pixel-art", "rings"
];
+// NEW: Define available slash commands for the previewer.
+const COMMANDS = {
+ "/roll": "[dice] - Rolls dice (e.g., 2d6+3). Defaults to 1d20.",
+ "/ooc": "[message] - Sends an out-of-character message.",
+ "/remember": "[fact] - Saves a fact to the GM's long-term memory.",
+ "/save": "- Saves the current game session.",
+ "/next": "- Submits the current turn actions to the GM."
+};
+
+
/**
* Initializes and returns the UI module.
* @param {AppState} state - The central state object.
@@ -84,6 +95,7 @@ export function initUI(state) {
sendButton: document.getElementById('send-button'),
resolveButton: document.getElementById('resolve-button'),
gmThinkingIndicator: document.getElementById('gm-thinking-indicator'),
+ commandPreview: document.getElementById('command-preview'), // Command preview element
themeToggle: document.getElementById('theme-toggle'),
};
@@ -94,16 +106,12 @@ export function initUI(state) {
/** Main render function to switch between primary UI views */
function _render() {
const mainElement = document.querySelector('main');
-
- // Hide all view containers initially
dom.loginView.style.display = 'none';
dom.registerView.style.display = 'none';
dom.mainAppView.style.display = 'none';
- // Set a class on the element to control the overall layout via CSS
if (state.uiView === 'login' || state.uiView === 'register') {
mainElement.className = 'auth-mode';
- // Show the correct auth form
if (state.uiView === 'login') {
dom.loginView.style.display = 'flex';
} else {
@@ -113,7 +121,6 @@ export function initUI(state) {
mainElement.className = 'app-mode';
dom.mainAppView.style.display = 'flex';
- // This logic to show/hide sections within the app remains the same
if (state.playerInfo) {
dom.playerIdentity.style.display = 'flex';
dom.playerCardName.textContent = state.playerInfo.name;
@@ -131,6 +138,27 @@ export function initUI(state) {
}
}
}
+
+ /** NEW: Updates the command preview based on input */
+ function _updateCommandPreview() {
+ const text = dom.messageInput.value;
+ if (text.startsWith('/')) {
+ const [typedCmd] = text.split(' ');
+ let html = '
Commands
';
+ for (const [cmd, desc] of Object.entries(COMMANDS)) {
+ if (cmd.startsWith(typedCmd)) {
+ html += `