mirror of
https://github.com/Nighthawk42/VDM.git
synced 2026-08-30 04:30:21 +00:00
Future updates...
This commit is contained in:
+52
-54
@@ -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'(<v name=".*?">.*?</v>)', 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'<v name="(.*?)">(.*?)</v>', 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:
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
+98
-29
@@ -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.")
|
||||
"""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.")
|
||||
+32
-9
@@ -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}'.")
|
||||
|
||||
+8
-3
@@ -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]
|
||||
@@ -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)
|
||||
+52
-29
@@ -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(
|
||||
|
||||
+79
-89
@@ -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.")
|
||||
+6
-4
@@ -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"
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@
|
||||
|
||||
<header>
|
||||
<div id="app-title">VDM</div>
|
||||
<button id="theme-toggle" title="Toggle Light/Dark Mode">🌙</button>
|
||||
<button id="theme-toggle" title="Toggle Light/Dark Mode">💡</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
@@ -72,7 +72,7 @@
|
||||
<img id="player-card-avatar" src="" alt="Player Avatar">
|
||||
<span id="player-card-name"></span>
|
||||
</div>
|
||||
<button type="button" id="logout-button">Change Identity</button>
|
||||
<button type="button" id="logout-button">Logout</button>
|
||||
</div>
|
||||
<div id="connection-form" class="sidebar-section" style="display: none;">
|
||||
<h3>Join a Game</h3>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+67
-36
@@ -9,7 +9,8 @@ import { initAudio } from './audio.js';
|
||||
/**
|
||||
* @typedef {object} AppUI - The public interface of the UI module.
|
||||
* @property {(api: ReturnType<ApiModule>) => 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 <main> 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 = '<h4>Commands</h4><ul>';
|
||||
for (const [cmd, desc] of Object.entries(COMMANDS)) {
|
||||
if (cmd.startsWith(typedCmd)) {
|
||||
html += `<li><strong>${cmd}</strong>: ${desc}</li>`;
|
||||
}
|
||||
}
|
||||
html += '</ul>';
|
||||
dom.commandPreview.innerHTML = html;
|
||||
dom.commandPreview.style.display = 'block';
|
||||
} else {
|
||||
dom.commandPreview.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Other private functions (_updateAvatarSelectionUI, _populateAvatarGrid, etc. are unchanged) ---
|
||||
|
||||
function _updateAvatarSelectionUI() {
|
||||
const name = dom.registerNameInput.value.trim() || 'player';
|
||||
@@ -167,7 +195,7 @@ export function initUI(state) {
|
||||
|
||||
/** Attach all event listeners for the application */
|
||||
function _attachListeners() {
|
||||
// --- Auth Listeners ---
|
||||
// --- Auth & Connection Listeners (mostly unchanged) ---
|
||||
dom.switchToRegisterBtn.addEventListener('click', () => { state.uiView = 'register'; _render(); });
|
||||
dom.switchToLoginBtn.addEventListener('click', () => { state.uiView = 'login'; _render(); });
|
||||
|
||||
@@ -177,12 +205,10 @@ export function initUI(state) {
|
||||
const name = dom.registerNameInput.value.trim();
|
||||
const password = dom.registerPasswordInput.value;
|
||||
const confirm = dom.registerConfirmPasswordInput.value;
|
||||
|
||||
if (password !== confirm) {
|
||||
_showError(dom.registerError, "Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.register(name, state.selectedAvatarStyle, password);
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
@@ -199,7 +225,6 @@ export function initUI(state) {
|
||||
const name = dom.loginNameInput.value.trim();
|
||||
const password = dom.loginPasswordInput.value;
|
||||
const result = await api.login(name, password);
|
||||
|
||||
if (result.success) {
|
||||
state.playerInfo = result.data;
|
||||
localStorage.setItem('vdm-player', JSON.stringify(result.data));
|
||||
@@ -209,10 +234,15 @@ export function initUI(state) {
|
||||
_showError(dom.loginError, result.message);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
dom.logoutButton.addEventListener('click', () => api.logout());
|
||||
dom.joinButton.addEventListener('click', () => {
|
||||
const roomId = dom.roomIdInput.value.trim();
|
||||
if (roomId) api.connectToRoom(roomId);
|
||||
});
|
||||
dom.leaveButton.addEventListener('click', () => api.disconnect());
|
||||
|
||||
// --- Avatar Selection ---
|
||||
// --- Avatar Selection Listeners (unchanged) ---
|
||||
dom.registerNameInput.addEventListener('input', () => _updateAvatarSelectionUI());
|
||||
dom.avatarSelectionGrid.addEventListener('click', (e) => {
|
||||
const option = e.target.closest('.avatar-option');
|
||||
@@ -222,39 +252,34 @@ export function initUI(state) {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Connection Listeners ---
|
||||
dom.joinButton.addEventListener('click', () => {
|
||||
const roomId = dom.roomIdInput.value.trim();
|
||||
if (roomId) api.connectToRoom(roomId);
|
||||
});
|
||||
dom.leaveButton.addEventListener('click', () => api.disconnect());
|
||||
|
||||
// --- Chat Listeners ---
|
||||
dom.sendButton.addEventListener('click', () => {
|
||||
const message = dom.messageInput.value.trim();
|
||||
if (message) api.sendMessage('say', { message });
|
||||
dom.messageInput.value = '';
|
||||
_updateCommandPreview();
|
||||
dom.messageInput.focus();
|
||||
});
|
||||
|
||||
dom.messageInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
dom.sendButton.click();
|
||||
}
|
||||
});
|
||||
// NEW: Add input event listener for command preview
|
||||
dom.messageInput.addEventListener('input', _updateCommandPreview);
|
||||
|
||||
dom.resolveButton.addEventListener('click', () => api.sendMessage('submit_turn'));
|
||||
|
||||
// --- Host Listeners ---
|
||||
dom.startGameButton.addEventListener('click', () => api.sendMessage('start_game'));
|
||||
dom.resumeGameButton.addEventListener('click', () => api.sendMessage('resume_game'));
|
||||
|
||||
// --- Misc Listeners ---
|
||||
// --- Misc Listeners (unchanged) ---
|
||||
dom.themeToggle.addEventListener('click', () => {
|
||||
const isLight = document.body.classList.toggle('light-theme');
|
||||
localStorage.setItem('vdm-theme', isLight ? 'light' : 'dark');
|
||||
});
|
||||
|
||||
// Resume AudioContext on any user interaction
|
||||
const resumeAudio = () => {
|
||||
if (state.audioContext && state.audioContext.state === 'suspended') {
|
||||
state.audioContext.resume();
|
||||
@@ -269,9 +294,8 @@ export function initUI(state) {
|
||||
document.body.classList.toggle('light-theme', savedTheme === 'light');
|
||||
_populateAvatarGrid();
|
||||
_attachListeners();
|
||||
_render(); // Set the initial view based on loaded state
|
||||
_render(); // Set initial view
|
||||
|
||||
// --- Public UI Methods ---
|
||||
/** @type {AppUI} */
|
||||
const publicInterface = {
|
||||
setApi(apiModule) {
|
||||
@@ -281,7 +305,6 @@ export function initUI(state) {
|
||||
logMessage(type, data, isBatch = false) {
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.classList.add('msg', type);
|
||||
|
||||
if (type === 'system') {
|
||||
msgDiv.textContent = data.message;
|
||||
} else if (type === 'chat') {
|
||||
@@ -313,17 +336,28 @@ export function initUI(state) {
|
||||
msgDiv.appendChild(avatarImg);
|
||||
msgDiv.appendChild(contentDiv);
|
||||
}
|
||||
|
||||
dom.chatLog.appendChild(msgDiv);
|
||||
if (!isBatch) {
|
||||
dom.chatLog.scrollTop = dom.chatLog.scrollHeight;
|
||||
}
|
||||
},
|
||||
|
||||
// NEW: Function to load historical messages
|
||||
loadChatHistory(messages) {
|
||||
dom.chatLog.innerHTML = ''; // Clear the chat log first
|
||||
messages.forEach(msg => {
|
||||
// The 'chat' type is hardcoded as only chat messages are in history
|
||||
this.logMessage('chat', msg, true);
|
||||
});
|
||||
dom.chatLog.scrollTop = dom.chatLog.scrollHeight; // Scroll to bottom after batch rendering
|
||||
},
|
||||
|
||||
// --- Other public methods (updateRoomState, streaming handlers, etc.) are mostly unchanged ---
|
||||
|
||||
updateRoomState(room) {
|
||||
state.room = room;
|
||||
dom.roomName.textContent = room.room_id;
|
||||
dom.playerList.innerHTML = ''; // Clear previous list
|
||||
dom.playerList.innerHTML = '';
|
||||
|
||||
Object.values(room.players).forEach(player => {
|
||||
const playerLi = document.createElement('li');
|
||||
@@ -352,8 +386,7 @@ export function initUI(state) {
|
||||
dom.playerList.appendChild(playerLi);
|
||||
});
|
||||
|
||||
// Update UI based on game state
|
||||
const isHost = (room.host_player_id === state.clientId);
|
||||
const isHost = (state.playerInfo && room.host_player_id === state.playerInfo.id);
|
||||
const inLobby = room.game_state === "LOBBY";
|
||||
const gmIsProcessing = room.turn_state === "GM_PROCESSING";
|
||||
const actionsExist = Object.keys(room.current_turn_actions || {}).length > 0;
|
||||
@@ -367,10 +400,8 @@ export function initUI(state) {
|
||||
dom.sendButton.disabled = gmIsProcessing || inLobby;
|
||||
},
|
||||
|
||||
// --- View Changers ---
|
||||
showRoomView(roomId) {
|
||||
state.isConnected = true;
|
||||
state.room = { room_id: roomId, players: {} }; // temporary state
|
||||
_render();
|
||||
},
|
||||
showConnectionView() {
|
||||
@@ -379,10 +410,11 @@ export function initUI(state) {
|
||||
},
|
||||
showLoginView() {
|
||||
state.uiView = 'login';
|
||||
state.isConnected = false;
|
||||
state.playerInfo = null; // Ensure player info is cleared
|
||||
_render();
|
||||
},
|
||||
|
||||
// --- Streaming Handlers ---
|
||||
handleStreamStart() {
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.classList.add('msg', 'chat', 'gm', 'streaming');
|
||||
@@ -406,7 +438,6 @@ export function initUI(state) {
|
||||
messageElement: msgDiv,
|
||||
contentElement: messageSpan,
|
||||
};
|
||||
|
||||
audio.startStream();
|
||||
},
|
||||
|
||||
|
||||
+57
-11
@@ -1,5 +1,5 @@
|
||||
/* =================================================================== */
|
||||
/* VDM - FINAL STYLE SHEET */
|
||||
/* VDM - FINAL STYLE SHEET */
|
||||
/* =================================================================== */
|
||||
|
||||
/* --- 1. Root Variables & Theming --- */
|
||||
@@ -48,14 +48,12 @@ main {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* In auth-mode, center the content vertically and horizontally */
|
||||
main.auth-mode {
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding-top: 10vh;
|
||||
}
|
||||
|
||||
/* Style the auth containers to look like a floating panel */
|
||||
main.auth-mode > .auth-form-container {
|
||||
width: 380px;
|
||||
max-width: 90%;
|
||||
@@ -72,7 +70,6 @@ body.light-theme main.auth-mode > .auth-form-container {
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* In app-mode, the main-app-view container takes up all the space */
|
||||
main.app-mode > #main-app-view {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -87,13 +84,11 @@ body.light-theme .sidebar { background-color: var(--surface-light); border-right
|
||||
.sidebar-section h3, .sidebar-section h4 { color: var(--text-primary-dark); border-bottom: 1px solid var(--border-dark); padding-bottom: 0.5rem; font-size: 1.1rem; }
|
||||
body.light-theme .sidebar-section h3, body.light-theme .sidebar-section h4 { color: var(--text-primary-light); border-bottom-color: var(--border-light); }
|
||||
|
||||
/* Auth Forms: Shared Styles */
|
||||
.auth-form-container h3 { text-align: center; }
|
||||
.auth-form-container .link-button { background: none; border: none; color: var(--link-blue); text-decoration: underline; padding: 0.25rem 0.5rem; cursor: pointer; font-size: 0.85rem; }
|
||||
body.light-theme .auth-form-container .link-button { color: var(--accent-light); }
|
||||
.auth-form-container .link-button:hover { text-decoration: none; }
|
||||
|
||||
/* Avatar Selection */
|
||||
.avatar-preview-container { display: flex; align-items: center; gap: 0.75rem; background-color: var(--bg-dark); padding: 0.5rem; border-radius: 8px; border: 1px solid var(--border-dark); margin-bottom: 0.5rem;}
|
||||
body.light-theme .avatar-preview-container { background-color: var(--bg-light); border-color: var(--border-light); }
|
||||
#register-avatar-preview { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; border: 2px solid var(--border-dark); }
|
||||
@@ -104,16 +99,13 @@ body.light-theme #register-avatar-preview { border-color: var(--border-light); }
|
||||
.avatar-option.selected { border-color: var(--accent-dark); }
|
||||
body.light-theme .avatar-option:hover { border-color: var(--border-light); }
|
||||
body.light-theme .avatar-option.selected { border-color: var(--accent-light); }
|
||||
.avatar-option img { width: 100%; height: auto; border-radius: 6px; display: block; }
|
||||
|
||||
/* Player Identity Card */
|
||||
.player-card { display: flex; align-items: center; gap: 1rem; background-color: var(--bg-dark); padding: 0.75rem; border-radius: 8px; border: 1px solid var(--border-dark); margin-bottom: 0.5rem;}
|
||||
body.light-theme .player-card { background-color: var(--bg-light); border-color: var(--border-light); }
|
||||
#player-card-avatar { width: 40px; height: 40px; border-radius: 50%; border: 2px solid var(--border-dark); }
|
||||
body.light-theme #player-card-avatar { border-color: var(--border-light); }
|
||||
#player-card-name { font-weight: 700; font-size: 1.1rem; }
|
||||
|
||||
/* Room Info */
|
||||
#player-list-container { max-height: 200px; overflow-y: auto; background-color: var(--bg-dark); padding: 0.75rem; border-radius: 8px; border: 1px solid var(--border-dark); }
|
||||
body.light-theme #player-list-container { background-color: var(--bg-light); border-color: var(--border-light); }
|
||||
#player-list { list-style-type: none; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
@@ -126,7 +118,6 @@ body.light-theme .player-list-avatar { background-color: var(--border-light); }
|
||||
.player-hp { font-size: 0.8rem; font-weight: 500; color: var(--text-secondary-dark); background-color: var(--bg-dark); padding: 0.15rem 0.4rem; border-radius: 4px; white-space: nowrap; }
|
||||
body.light-theme .player-hp { color: var(--text-secondary-light); background-color: var(--bg-light); }
|
||||
|
||||
/* Host Controls */
|
||||
#host-controls-container { display: flex; flex-direction: column; gap: 1rem; }
|
||||
|
||||
/* --- 5. Generic Form & Button Styling --- */
|
||||
@@ -194,4 +185,59 @@ body.light-theme .msg code { background-color: var(--bg-light); }
|
||||
#gm-thinking-indicator { display: none; position: absolute; bottom: calc(64px + 1rem); left: 50%; transform: translateX(-50%); background-color: var(--surface-dark); padding: 0.5rem 1rem; border-radius: 1rem; align-items: center; gap: 0.5rem; color: var(--text-secondary-dark); border: 1px solid var(--border-dark); font-size: 0.9rem; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2); }
|
||||
body.light-theme #gm-thinking-indicator { background-color: var(--surface-light); color: var(--text-secondary-light); border-color: var(--border-light); box-shadow: 0 4px 14px rgba(0, 0, 0, 0.1); }
|
||||
.spinner { width: 16px; height: 16px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* --- 9. NEW: Command Preview --- */
|
||||
#command-preview {
|
||||
display: none; /* Hidden by default */
|
||||
position: absolute;
|
||||
bottom: 100%; /* Position it right above the input area */
|
||||
left: 1.5rem;
|
||||
right: 1.5rem;
|
||||
background-color: var(--bg-dark);
|
||||
border: 1px solid var(--border-dark);
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 1rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 -4px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
body.light-theme #command-preview {
|
||||
background-color: var(--bg-light);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
#command-preview h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary-dark);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
body.light-theme #command-preview h4 {
|
||||
color: var(--text-secondary-light);
|
||||
}
|
||||
|
||||
#command-preview ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#command-preview li {
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary-dark);
|
||||
}
|
||||
body.light-theme #command-preview li {
|
||||
color: var(--text-secondary-light);
|
||||
}
|
||||
|
||||
#command-preview li strong {
|
||||
color: var(--text-primary-dark);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
body.light-theme #command-preview li strong {
|
||||
color: var(--text-primary-light);
|
||||
}
|
||||
Reference in New Issue
Block a user