Future updates...

This commit is contained in:
Nighthawk
2025-09-05 01:15:54 -04:00
parent da0eff83fb
commit ec6f1065bf
13 changed files with 473 additions and 275 deletions
+52 -54
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+10 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}