diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..269e474 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# =================================================================== +# VDM - Environment Configuration & Secrets +# =================================================================== +# Copy this file to .env and fill in your actual values. +# This file contains secrets and environment-specific settings. +# IMPORTANT: DO NOT commit the .env file to version control. + +# --- Server & Network --- +# The host IP the server will bind to. Use 0.0.0.0 for network access, 127.0.0.1 for local only. +SERVER_HOST="0.0.0.0" +# The network port the server will run on. +SERVER_PORT="8000" + + +# --- LLM Provider Secrets & URLs --- +# Required only if you use the "openrouter" backend. Get from https://openrouter.ai/keys +OPENROUTER_API_KEY="your-openrouter-api-key-here" + +# The base URL for your local Ollama server. +OLLAMA_BASE_URL="http://localhost:11434" + +# The base URL for your local LM Studio server's OpenAI-compatible API. +LMSTUDIO_BASE_URL="http://localhost:1234/v1" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb99d4a --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.env +*.wav +*.pyc +memory/chroma_db/85bfab4b-517c-4773-b8a8-e8358c4e4337/data_level0.bin +memory/chroma_db/85bfab4b-517c-4773-b8a8-e8358c4e4337/header.bin +memory/chroma_db/85bfab4b-517c-4773-b8a8-e8358c4e4337/length.bin +memory/chroma_db/85bfab4b-517c-4773-b8a8-e8358c4e4337/link_lists.bin +memory/chroma_db/chroma.sqlite3 +memory/users.json +memory/vdm_sessions.db diff --git a/prompts.yml b/prompts.yml new file mode 100644 index 0000000..e1d4c0e --- /dev/null +++ b/prompts.yml @@ -0,0 +1,89 @@ +# =================================================================== +# VDM - Prompt Configuration +# =================================================================== +# This file contains all system prompts used by the AI Game Master. +# You can customize the GM's personality and instructions here. + +# --- Prompt for the initial game setup phase --- +setup: > + You are the Virtual Dungeon Master (VDM). Your first job is to collaborate with the players + to decide on the game's setting. Your personality is friendly, concise, and helpful. + Your task is to greet the players and ask them what kind of adventure they want to play. + Ask about genre (e.g., fantasy, sci-fi, horror), tone (e.g., serious, lighthearted), + and a brief description of the setting. Keep your request to a single, welcoming paragraph. + +# --- Prompt for resuming a game in progress --- +resume_game: > + You are the Virtual Dungeon Master (VDM). The players are returning to a game in progress. + Your task is to provide a concise, engaging summary of the current situation based on the + provided memories and the last few messages. Re-establish the scene, remind the players + of the immediate circumstances and any pressing dangers or questions, and then prompt them + to act by asking "What do you do?". + +# --- Main prompt for ongoing gameplay --- +gameplay: + # The core instructions and personality for the GM. + base: > + You are the Virtual Dungeon Master (VDM), a master storyteller. Your primary goal is to + create a fun, engaging, and collaborative narrative experience. + + Your personality: + - Creative and descriptive: Paint vivid pictures of the world, characters, and events. + - Fair and adaptive: Respond to player actions logically and dynamically. + - Guiding, not controlling: Never dictate a player's actions. Instead, present situations. + - Concise: Keep your responses to 1-3 paragraphs. + + # --- Instruction for JSON-based input --- + # This is the primary, recommended way for the GM to receive player turn data. + json_input_instruction: > + CRITICAL INPUT FORMAT: For each turn, you will receive a JSON array detailing each player's + contribution. Each object in the array contains the player's name, their physical 'action', + and their spoken 'dialogue'. Your job is to narrate the collective outcome of these inputs. + + CRITICAL AGENCY RULE: You must NEVER generate dialogue for a player character. Only use the + dialogue provided in the JSON input. Narrate their actions and the world's reaction to them. + + INPUT EXAMPLE: + ```json + [ + { + "player_name": "Player 1", + "action": "Approaches the large Gamorrean at the stage.", + "dialogue": "What's your name, big guy?" + }, + { + "player_name": "Player 2", + "action": "Finishes his song and gives a hearty laugh.", + "dialogue": "Chris P. Bacon, at your service!" + } + ] + ``` + + # --- FALLBACK: Instruction for legacy text-based input --- + # This is for models that may not handle structured JSON well. + legacy_text_input_instruction: > + PLAYER ACTIONS: For each turn, you will receive a list of player actions. Your job is to + narrate the outcome of these actions. Example: + [Player 1]: Approaches the Gamorrean. "What's your name?" + [Player 2]: Laughs heartily. "Chris P. Bacon, at your service!" + + # --- Instruction for Voice Tagging --- + # This tells the AI how to format dialogue for our Dynamic Voice Casting system. + voice_tagging_instruction: > + IMPORTANT VOICE RULE: When a character or creature speaks, you MUST enclose their dialogue + in a tag with their name. All text outside of a tag is considered narration. + The character name should be simple and consistent. + + Narration and Dialogue Example: + The old man looks up from his book. You are late. He says, + slamming the book shut. Just as I foretold. + + # An additional instruction for models that support reasoning tags. + tagging_instruction: > + CRITICAL OUTPUT FORMAT: You MUST format your entire output in two parts using XML-style tags: + a block and a block. + + Example: + The player wants to inspect the chest. I'll make the lock unusual. + You approach the heavy oaken chest. Instead of a keyhole, you see a small, + circular indentation with three strange runes carved around it. What do you do? \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..b616244 --- /dev/null +++ b/readme.md @@ -0,0 +1,150 @@ +# VDM - The Virtual Dungeon Master + +VDM is a multiplayer, AI-driven storytelling game designed for immersive, collaborative role-playing. It combines a powerful, locally-run backend with a clean web interface, allowing you and your friends to create and experience epic adventures narrated by a sophisticated AI Game Master. + +The project is built with a "Keep It Simple, Stupid" (KISS) philosophy, leveraging modern, high-performance tools to create a robust foundation that is easy to understand, maintain, and extend. + +![VDM Thematic UI Screenshot](https://i.imgur.com/your-screenshot-url.png) + +--- + +## ✨ Features + +* **AI-Powered Game Master:** A sophisticated LLM (Large Language Model) acts as the storyteller, reacting to player actions, describing the world, and narrating events. +* **Real-Time Multiplayer:** Join a room with friends from anywhere. The game state is synchronized in real-time for a seamless collaborative experience. +* **Thematic & Modern UI:** A beautiful and immersive user interface with selectable "Material" and "Thematic" (fantasy manuscript) styles, complete with a persistent light/dark mode. +* **Dynamic Voice Narration:** The GM's responses are brought to life with high-quality, server-side Text-to-Speech. +* **Long-Term Memory (RAG):** The AI has a true long-term memory, powered by a local vector database (`ChromaDB`). It automatically remembers key events and can be manually prompted to remember specific facts with the `/remember` command. +* **Session Persistence:** Save your game at any time with the `/save` command. The server automatically reloads your session when you rejoin the room, so you can continue your adventure later. +* **Turn-Based Gameplay:** A structured turn system allows players to declare their actions, which are then submitted to the GM as a single turn for resolution. +* **Player-Driven Setup:** The adventure begins with the AI collaborating with the players to define the genre, tone, and setting of the story. +* **Secure & Accessible:** Runs locally on your machine and can be securely accessed over your network (LAN, ZeroTier, Hamachi) via HTTPS. +* **Pluggable AI Backends:** Easily switch between different LLM providers, including local options like **LM Studio** and **Ollama**, or cloud services like **OpenRouter**. + +--- + +## 🚀 Getting Started + +Follow these steps to get your VDM server up and running. + +### Prerequisites + +* **Python 3.11+** +* **`uv`:** A fast Python package installer. If you don't have it, run: + ```bash + pip install uv + ``` +* **`mkcert`:** For generating a trusted local SSL certificate (required for microphone access). [See mkcert installation instructions](https://github.com/FiloSottile/mkcert). +* **(Optional) NVIDIA GPU:** For the best performance with local LLMs and RVC. + +--- + +### 1. Project Setup + +First, clone or download the project repository. + +--- + +### 2. Create the Virtual Environment + +We use `uv` to create a consistent and fast virtual environment. Open your terminal or command prompt in the project's root directory and run: + +```bash +# This creates a .venv folder using Python 3.11 +uv venv --python 3.11 --seed +``` + +--- + +### 3. Activate the Environment + +You must activate the environment in your terminal session before installing packages or running the server. + +**On Windows (Command Prompt/PowerShell):** +```cmd +.venv\Scripts\activate +``` + +**On macOS / Linux:** +```bash +source .venv/bin/activate +``` + +Your terminal prompt should now be prefixed with `(.venv)`. + +--- + +### 4. Install Dependencies + +Install all required Python packages using the requirements.txt file. + +```bash +# This will install FastAPI, PyTorch, ChromaDB, and all other dependencies +uv pip install -r requirements.txt +``` + +> **Note:** The first time you run this, it may take a few minutes to download the PyTorch libraries and the Sentence Transformer model for the RAG system. + +--- + +### 5. Configure the VDM + +The VDM is configured using simple YAML files. + +* **Main Configuration:** Copy `config.yml.example` to `config.yml`. Open the new file and configure it, paying special attention to the `llm` section to select your AI backend (`lmstudio`, `ollama`, `openrouter`) and provide your API key if needed. +* **Prompts (Optional):** Edit `prompts.yml` to change the GM's personality and instructions. +* **Voices (Optional):** If you enable `enable_dynamic_casting` in your config, edit `voices.yml` to assign custom voices to characters. + +--- + +### 6. Generate SSL Certificate (for HTTPS) + +The microphone feature requires a secure (HTTPS) connection. + +1. **(One-Time Setup) Install a local Certificate Authority:** + ```bash + mkcert -install + ``` + +2. **Generate Certificate:** + From your project's root directory, create a `ssl` folder. Then run the mkcert command, replacing `` with your actual local network or ZeroTier IP address. + + ```bash + mkdir ssl + mkcert -key-file ./ssl/key.pem -cert-file ./ssl/cert.pem localhost 127.0.0.1 ::1 + ``` + +--- + +### 7. Launch the Server! + +Simply run the launch script. It will handle activating the environment and starting the server with all the correct settings. + +**On Windows:** +```cmd +launch.bat +``` + +The server will be running at [https://localhost:8000](https://localhost:8000) (or your configured port). You and your friends can now connect and play! + +--- + +## 🎮 How to Play + +1. **Connect:** Open your browser to the server's HTTPS address. Enter a Room ID and a Player Name. +2. **Lobby:** Wait for your friends to join. The first player in the room is the host and will see a "Start Game" button. +3. **Start Game:** The host clicks "Start Game" to begin the collaborative setup. +4. **Define Your World:** The GM will ask what kind of adventure you want to play. Anyone can reply. The first in-character reply sets the stage for the game. +5. **Declare Actions:** During gameplay, type what your character does or says. This adds your action to the current turn's queue. +6. **Submit the Turn:** When all players have declared their actions, anyone can click the "Continue Story" button (or type `/next`) to submit the turn to the GM. +7. **Enjoy the Story:** The GM will narrate the outcome of your combined actions. + +--- + +## ⌨️ Slash Commands + +* `/roll [dice]`: Rolls dice (e.g., `/roll 2d6+3`). Defaults to `1d20`. +* `/ooc [message]`: Sends an out-of-character message to other players. +* `/remember [fact]`: Saves a critical fact to the GM's long-term memory. +* `/save`: Saves the current game session. +* `/next`: Submits the current turn's actions to the GM. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..07ff5cd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,30 @@ +# Core server +fastapi +uvicorn +pydantic +starlette +orjson +httpx + +# Vector & embeddings +chromadb +sentence-transformers +# NumPy 2 + SciPy that plays nice with it +numpy==2.0 +scipy + +# Audio stack (modern, NumPy 2 compatible) +librosa +soundfile +soxr + +# TTS / GPU bits you were already using (pin to your known-good set) +#torch +#torchaudio +#torchvision + +# Kokoro TTS (your current) +kokoro + +# Misc already in your tree +rich \ No newline at end of file diff --git a/roadmap.md b/roadmap.md new file mode 100644 index 0000000..df525e2 --- /dev/null +++ b/roadmap.md @@ -0,0 +1,90 @@ +# VDM Project Roadmap + +This document outlines the history, current state, and planned future of the Virtual Dungeon Master application. + +--- + +## ✅ **Phase 1: Core Systems (Completed)** + +This phase focused on building a stable, feature-rich, and fully playable foundation. All items in this section are implemented and working. + +- **Core Backend & Networking:** + - [x] FastAPI server with WebSocket manager. + - [x] LAN/VPN accessibility via `0.0.0.0` and HTTPS/SSL support. + - [x] CORS configuration for deployment flexibility. + +- **Professional Tooling:** + - [x] Centralized YAML-based configuration (`config.yml`, `prompts.yml`, `voices.yml`). + - [x] Type-safe Pydantic settings models. + - [x] Beautiful console logging with `rich` and intelligent warning suppression. + +- **AI & Storytelling:** + - [x] Pluggable LLM provider system (LM Studio, Ollama, OpenRouter). + - [x] Externalized, customizable GM prompts. + - [x] Configurable LLM tag parsing (``/``). + +- **Long-Term Memory (RAG):** + - [x] Custom RAG pipeline using `sentence-transformers` and `ChromaDB`. + - [x] Automatic memory creation from GM responses. + - [x] Manual memory creation via the `/remember` command. + - [x] RAG-augmented prompts to provide the AI with long-term context. + +- **Audio Narration:** + - [x] High-quality TTS using the stable `kokoro` (PyTorch) library. + - [x] Intelligent text sanitization for clean, immersive audio. + +- **Frontend UI/UX:** + - [x] Modern, single-page application with a polished Material Design theme. + - [x] Persistent Light/Dark mode. + - [x] Discord-style slash command preview. + - [x] Dynamic, auto-generating player avatars. + +- **Game Mechanics & Persistence:** + - [x] Full session saving and loading (`/save` command and automatic loading). + - [x] Multiplayer lobby system with a designated host and "Start Game" flow. + - [x] Turn-based action system (`/next` command or "Continue Story" button). + - [x] Dice roller (`/roll`) and OOC chat (`/ooc`). + +--- + +## 🎯 **Phase 2: Advanced Immersion & Interaction (Current Focus)** + +This phase is about adding layers of dynamic interaction and immersion on top of our stable foundation. + +### 1. Dynamic Voice Casting System (RVC Integration) +- **Goal:** Allow the GM to use different voices for different characters, including custom voice models. +- **Status:** The configuration (`voices.yml`), prompting (`` tags), and feature flags are **DONE**. +- **To-Do:** + - [ ] Rebuild the `AudioManager` into an "Audio Director" that parses `` tags, consults the casting sheet, and orchestrates the TTS/RVC pipeline for each dialogue segment. + - [ ] Fully integrate the PyTorch-based `tts-with-rvc` library for handling voice conversions. + +### 2. Speech-to-Text (Client-Side) +- **Goal:** Allow players to speak their actions instead of typing. +- **Plan:** + - [ ] Implement the browser's **Web Speech API**, which requires no backend changes. + - [ ] Add a "Hold to Talk" microphone button to the UI. + - [ ] The browser will handle transcription, and the resulting text will be placed in the input box. + +--- + +## 🚀 **Phase 3: Structured Gameplay & World Systems** + +This phase will transform the VDM from a pure storyteller into a true "Game Master" that understands and enforces rules. + +### 1. Structured Game Mechanics (`game_manager.py`) +- **Character Sheets:** Implement a simple Pydantic model for character sheets (`hp`, `stats`, `inventory`) and store them in the `Room` state. +- **Inventory System:** Allow the LLM to grant items via a special tag (e.g., ``), which is then parsed by the server and added to a player's character sheet. +- **Systematized Skill Checks:** This is a major goal. + 1. Teach the LLM to request a skill check instead of deciding an outcome (e.g., ``). + 2. The server parses this, calls our `DiceRoller`, compares the result to the difficulty, and determines success/failure. + 3. The server then calls the LLM *again* with the result ("System: The dexterity check succeeded. Narrate the outcome."). + 4. This separates the **Rules Arbitrator** (our code) from the **Storyteller** (the AI). + +### 2. Deeper AI & World Integration +- **AI-Powered Image Generation:** Allow the GM to generate images for scenes or characters via a tag like ``. The server would send this to an image generation API and display the result in the chat. +- **NPC Management:** Create a dedicated system for managing Non-Player Characters, storing their character sheets, personalities, and key memories in the RAG database. + +### 3. Production & Quality of Life +- **User Authentication:** A simple user system to allow for persistent player identities. +- **Database Backend:** For larger scale, migrate from JSON file persistence to a more robust database like SQLite. +- **Admin Dashboard:** A simple web interface for the server host to view logs, manage rooms, and adjust AI settings on the fly. \ No newline at end of file diff --git a/server/audio_manager.py b/server/audio_manager.py new file mode 100644 index 0000000..4ac99ea --- /dev/null +++ b/server/audio_manager.py @@ -0,0 +1,242 @@ +# server/audio_manager.py +from __future__ import annotations + +import re +import uuid +import yaml +import importlib +from pathlib import Path +from typing import Dict, Any, List, Optional, TYPE_CHECKING, AsyncGenerator + +import numpy as np +import soundfile as sf +from kokoro import KPipeline +import torch + +from .config import settings +from .logger import logger + +RVC_ENABLED = False +TTS_RVC: Any = None +try: + _rvc_mod = importlib.import_module("tts_with_rvc") + TTS_RVC = getattr(_rvc_mod, "TTS_RVC", None) + RVC_ENABLED = TTS_RVC is not None +except ImportError: + logger.warning("`tts-with-rvc` not installed. RVC functionality is disabled.") + +if TYPE_CHECKING: + from tts_with_rvc import TTS_RVC as TTS_RVC_Type +else: + TTS_RVC_Type = Any + + +class AudioManager: + 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] = {} + self._rvc_instances: Dict[str, TTS_RVC_Type] = {} + self.pipeline: Optional[KPipeline] = None + + try: + logger.info("Loading official Kokoro TTS pipeline...") + self.pipeline = KPipeline(lang_code="a", repo_id="hexgrad/Kokoro-82M") + logger.info("Kokoro TTS pipeline loaded successfully.") + except Exception as e: + logger.critical(f"Could not load Kokoro TTS pipeline: {e}", exc_info=True) + raise + + if settings.audio.enable_dynamic_casting: + logger.info("Dynamic Voice Casting is ENABLED.") + self._load_voice_casting_sheet() + if RVC_ENABLED: + self._initialize_rvc_instances() + else: + logger.info("Dynamic Voice Casting is DISABLED.") + + def _load_voice_casting_sheet(self) -> None: + 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.") + return + with open(path, "r", encoding="utf-8") as f: + self.voice_cast = yaml.safe_load(f) or {} + logger.info(f"Successfully loaded voice casting sheet from '{path}'.") + except Exception: + logger.error("Failed to load or parse voices.yml.", exc_info=True) + + def _initialize_rvc_instances(self) -> None: + # (This method is for future RVC implementation) + pass + + @staticmethod + def _sanitize_for_tts(text: str) -> str: + text = re.sub(r"[\*_]", "", text) + text = re.sub(r"\[.*?\]", "", text) + text = re.sub(r"\(.*?\)", "", text) + text = re.sub(r"\s+", " ", text).strip() + return text + + def _normalize_audio_chunk(self, audio_chunk: Any) -> Optional[np.ndarray]: + if audio_chunk is None: return None + if hasattr(audio_chunk, "detach"): + try: + audio_chunk = audio_chunk.detach().cpu().numpy() + except Exception: return None + if isinstance(audio_chunk, np.ndarray): + return audio_chunk.flatten().astype(np.float32, copy=False) + return None + + async def _synthesize_kokoro_stream(self, text: str, voice_name: str) -> AsyncGenerator[bytes, None]: + if not self.pipeline: + logger.error("Kokoro pipeline not initialized. Cannot synthesize.") + return + sanitized_text = self._sanitize_for_tts(text) + if not sanitized_text: return + generator = self.pipeline(sanitized_text, voice=voice_name, speed=1) + for _, _, chunk in generator: + normalized_chunk = self._normalize_audio_chunk(chunk) + if normalized_chunk is not None and normalized_chunk.size > 0: + yield normalized_chunk.tobytes() + + async def _synthesize_rvc_non_stream(self, text: str, character_name: str) -> np.ndarray: + char_key = character_name.lower() + narrator_voice = self.voice_cast.get("defaults", {}).get("narrator", settings.audio.default_voice) + + 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]: + 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): + yield chunk + else: + segments = re.split(r'(.*?)', text, flags=re.DOTALL) + for segment in segments: + if not segment.strip(): continue + dialogue_match = re.match(r'(.*?)', segment, flags=re.DOTALL) + if dialogue_match: + char_name, dialogue_text = dialogue_match.groups() + if char_name.lower() in self._rvc_instances and RVC_ENABLED: + logger.warning(f"Skipping RVC character '{char_name}' in streaming mode as it's not supported.") + continue + 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)) + async for chunk in self._synthesize_kokoro_stream(dialogue_text, kokoro_voice): + yield chunk + else: + narrator_voice = self.voice_cast.get("defaults", {}).get("narrator", settings.audio.default_voice) + async for chunk in self._synthesize_kokoro_stream(segment, narrator_voice): + yield chunk + + async def synthesize(self, text: str, voice: Optional[str] = None) -> str: + if not text.strip(): + logger.warning("Synthesize called with empty text.") + return "" + + full_audio_segments: List[np.ndarray] = [] + + 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: + full_audio_segments.append(np.frombuffer(audio_bytes, dtype=np.float32)) + else: + segments = re.split(r'(.*?)', text, flags=re.DOTALL) + for segment in segments: + sanitized_segment = self._sanitize_for_tts(segment) + if not sanitized_segment: continue + + dialogue_match = re.match(r'(.*?)', segment, flags=re.DOTALL) + if dialogue_match: + char_name, dialogue_text = dialogue_match.groups() + if char_name.lower() in self._rvc_instances and RVC_ENABLED: + audio_array = await self._synthesize_rvc_non_stream(dialogue_text, char_name) + full_audio_segments.append(audio_array) + else: + 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: + 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: + full_audio_segments.append(np.frombuffer(audio_bytes, dtype=np.float32)) + + if not full_audio_segments: + logger.warning("TTS generation produced no audio.") + return "" + + full_audio = np.concatenate([seg for seg in full_audio_segments if seg.size > 0]) + if full_audio.size == 0: + logger.warning("TTS concatenation resulted in empty audio.") + return "" + + filename = f"{uuid.uuid4().hex}.wav" + output_path = self.output_dir / filename + sf.write(output_path, full_audio, 24000) + + url_path = f"/audio/{filename}" + logger.info(f"Final audio synthesized successfully to '{url_path}'") + return url_path + + def list_voices(self) -> Dict[str, List[str]]: + 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) + for v in chars.values(): + if isinstance(v, dict): + kv = v.get("kokoro_voice") + if kv and kv not in kokoro_list: + kokoro_list.append(kv) + return {"kokoro": sorted(list(set(kokoro_list)))} + except Exception: + logger.error("Failed to list voices.", exc_info=True) + return {"kokoro": ["af_heart"]} \ No newline at end of file diff --git a/server/config.py b/server/config.py new file mode 100644 index 0000000..1a3e38d --- /dev/null +++ b/server/config.py @@ -0,0 +1,79 @@ +# server/config.py +import yaml +from typing import Literal +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import BaseModel, Field + +# --- Provider sub-models (for .env) --- +class OpenRouterSettings(BaseModel): + api_key: str + +class OllamaSettings(BaseModel): + base_url: str + +class LMStudioSettings(BaseModel): + base_url: str + +class LLMProvidersFromEnv(BaseModel): + openrouter: OpenRouterSettings + ollama: OllamaSettings + lmstudio: LMStudioSettings + +# --- Pydantic models for settings.yml --- +class LLMSettings(BaseModel): + backend: Literal["openrouter", "ollama", "lmstudio"] + story_model: str + prompting_strategy: Literal["json", "legacy_text"] + llm_uses_tags: bool + context_messages: int + +class AudioSettings(BaseModel): + enable_streaming: bool + enable_dynamic_casting: bool + default_voice: str + +class MemorySettings(BaseModel): + embedding_model: str + database_file: str + +class PathsSettings(BaseModel): + prompts_file: str + voices_file: str + memory_dir: str + audio_out_dir: str + +# --- Main Settings Class --- +class Settings(BaseSettings): + # From .env + server_host: str = Field("0.0.0.0", alias="SERVER_HOST") + server_port: int = Field(8000, alias="SERVER_PORT") + llm_providers: LLMProvidersFromEnv + + # From settings.yml + llm: LLMSettings + audio: AudioSettings + memory: MemorySettings + paths: PathsSettings + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + env_nested_delimiter="__", + ) + +def load_settings(path: str = "settings.yml") -> Settings: + """Load YAML and merge with env variables (env wins).""" + try: + with open(path, "r", encoding="utf-8") as f: + yaml_data = yaml.safe_load(f) or {} + return Settings.model_validate(yaml_data) + except FileNotFoundError: + print(f"ERROR: Settings file not found at '{path}'. Exiting.") + raise SystemExit(1) + except Exception as e: + print(f"ERROR: Failed to load or validate configuration from '{path}' or '.env': {e}. Exiting.") + raise SystemExit(1) + +# Global instance +settings = load_settings() \ No newline at end of file diff --git a/server/database_manager.py b/server/database_manager.py new file mode 100644 index 0000000..3a30f02 --- /dev/null +++ b/server/database_manager.py @@ -0,0 +1,85 @@ +# server/database_manager.py +import sqlite3 +import json +from pathlib import Path +from typing import Optional + +from .models import Room +from .logger import logger + +class DatabaseManager: + """Handles all direct SQLite database operations for VDM.""" + + def __init__(self, db_path: Path): + """ + Initializes the database connection and creates tables if they don't exist. + """ + self.db_path = db_path + self.db_path.parent.mkdir(parents=True, exist_ok=True) + 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() + 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.""" + try: + cursor = self.conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS rooms ( + room_id TEXT PRIMARY KEY, + room_data TEXT NOT NULL + ) + """) + self.conn.commit() + except sqlite3.Error as e: + logger.error(f"Failed to create database tables: {e}", exc_info=True) + + 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. + """ + try: + json_data = room.model_dump_json() + cursor = self.conn.cursor() + cursor.execute( + "INSERT OR REPLACE INTO rooms (room_id, room_data) VALUES (?, ?)", + (room.room_id, json_data) + ) + self.conn.commit() + logger.info(f"Successfully saved room '{room.room_id}' to the database.") + return True + except sqlite3.Error as e: + logger.error(f"Failed to save room '{room.room_id}' to database.", exc_info=True) + return False + + def load_room(self, room_id: str) -> Optional[Room]: + """ + Loads a room's state from the database using its ID. + """ + try: + cursor = self.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) + logger.info(f"Successfully loaded room '{room_id}' from the database.") + return room + else: + logger.info(f"No database entry found for room '{room_id}'.") + return None + except (sqlite3.Error, json.JSONDecodeError) as e: + logger.error(f"Failed to load room '{room_id}' from database.", exc_info=True) + return None + + def close(self): + """Closes the database connection.""" + if self.conn: + self.conn.close() + logger.info("Database connection closed.") \ No newline at end of file diff --git a/server/game_manager.py b/server/game_manager.py new file mode 100644 index 0000000..75119f1 --- /dev/null +++ b/server/game_manager.py @@ -0,0 +1,60 @@ +# server/game_manager.py +import re +import random +from typing import NamedTuple, List, Optional + +class DiceRollResult(NamedTuple): + """A structured result of a dice roll.""" + rolls: List[int] + modifier: int + total: int + as_string: str + +class DiceRoller: + """A utility class for parsing and rolling dice based on standard notation.""" + + # Pre-compiled regex for efficiency. + # Captures: (1) num_dice, (2) sides, (3) modifier + DICE_PATTERN = re.compile(r"(\d*)d(\d+)([+-]\d+)?", re.IGNORECASE) + + def roll(self, dice_string: str) -> Optional[DiceRollResult]: + """ + Parses a dice notation string (e.g., "1d20", "2d6+3"), rolls the dice, + and returns a structured result. Returns None if the string is invalid. + """ + match = self.DICE_PATTERN.fullmatch(dice_string.strip()) + if not match: + return None + + num_dice_str, sides_str, modifier_str = match.groups() + + num_dice = int(num_dice_str) if num_dice_str else 1 + sides = int(sides_str) + modifier = int(modifier_str) if modifier_str else 0 + + # Anti-abuse limits + if not (1 <= num_dice <= 100): + return None + if not (1 <= sides <= 1000): + return None + if not (-1000 <= modifier <= 1000): + return None + + # Perform the roll + rolls = [random.randint(1, sides) for _ in range(num_dice)] + total = sum(rolls) + modifier + + # Format the result into a user-friendly string + result_string = self._format_result(dice_string, rolls, modifier, total) + + return DiceRollResult(rolls=rolls, modifier=modifier, total=total, as_string=result_string) + + def _format_result(self, dice_string: str, rolls: List[int], modifier: int, total: int) -> str: + """Creates a pretty string for display, e.g., "(2d6+3) -> [5, 2] + 3 = 10" """ + rolls_str = str(rolls) + + if modifier != 0: + mod_str = f" + {modifier}" if modifier > 0 else f" - {abs(modifier)}" + return f"`{dice_string}` → {rolls_str}{mod_str} = **{total}**" + else: + return f"`{dice_string}` → {rolls_str} = **{total}**" \ No newline at end of file diff --git a/server/llm_providers.py b/server/llm_providers.py new file mode 100644 index 0000000..53342cd --- /dev/null +++ b/server/llm_providers.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import abc +import json +from typing import List, Dict, AsyncGenerator + +import httpx + +from .config import settings +from .logger import logger + +# =================================================================== +# LLM Provider Abstraction +# =================================================================== + +class LLMProvider(abc.ABC): + """Abstract base class for all LLM providers.""" + + @abc.abstractmethod + async def generate_completion_stream( + self, messages: List[Dict[str, str]] + ) -> AsyncGenerator[str, None]: + # dummy yield to satisfy abstract async generator + yield "This method needs to be implemented by a subclass" + + async def generate_completion_non_stream(self, messages: List[Dict[str, str]]) -> str: + full_response = "" + async for chunk in self.generate_completion_stream(messages): + full_response += chunk + return full_response + + +# =================================================================== +# STRICT LM Studio normalization for jinja template you posted +# =================================================================== + +def _normalize_for_lmstudio(messages: List[Dict[str, str]]) -> List[Dict[str, str]]: + """ + Produce a sequence that *strictly* alternates starting with 'user', + per the LM Studio jinja: + + loop_messages = messages (or messages[1:] if first is system) + assert loop_messages[0] == user + assert roles alternate user/assistant/user/assistant/... + + Strategy: + - Pull *all* system text into a prefix. + - Drop system messages from the list passed to LM Studio. + - Rebuild a new list that *forces* exact alternation: + expected role at index i: 'user' if i%2==0 else 'assistant' + * If the incoming message has the expected role -> append. + * Else -> merge its content into the previous appended message + (so we never create illegal alternation). + - Ensure there is at least one 'user' at the start; if not, synthesize one. + """ + sys_prefix = [] + non_system: List[Dict[str, str]] = [] + for m in messages: + role = m.get("role") + content = (m.get("content") or "").strip() + if not content and isinstance(m.get("content"), list): + # Handle multi-part content: keep only text pieces + parts = [] + for item in m["content"]: + if isinstance(item, dict) and item.get("type") == "text": + parts.append(item.get("text", "")) + content = "\n".join(p.strip() for p in parts if p.strip()) + if role == "system": + if content: + sys_prefix.append(content) + elif role in ("user", "assistant"): + non_system.append({"role": role, "content": content}) + + # If nothing left, create a single user with any system text + system_blob = "\n\n".join(sys_prefix).strip() + if not non_system: + return [{"role": "user", "content": system_blob}] + + # Build strictly alternating sequence + out: List[Dict[str, str]] = [] + + # First turn MUST be user. Seed it. + first_user_content = "" + if system_blob: + first_user_content = system_blob + + # Try to use the first incoming user content as well + # (if the first incoming message is user, fold system into it) + i = 0 + if non_system[0]["role"] == "user": + first_user_content = (first_user_content + ("\n\n" if first_user_content else "") + non_system[0]["content"]).strip() + i = 1 # we've consumed the first incoming message + + # If we still have no first user content, synthesize empty + out.append({"role": "user", "content": first_user_content}) + + # Walk remaining incoming messages and force alternation + expected = "assistant" # since we just pushed a user + while i < len(non_system): + m = non_system[i] + if m["role"] == expected: + # append as its own turn + out.append({"role": expected, "content": m["content"]}) + expected = "user" if expected == "assistant" else "assistant" + else: + # role mismatch: fold into the previous turn's content + # (safe because we maintain strict alternation in 'out') + out[-1]["content"] = (out[-1]["content"] + ("\n\n" if out[-1]["content"] else "") + m["content"]).strip() + # 'expected' unchanged; we did not advance alternation + i += 1 + + # Optional: trim leading/trailing empties for neatness (not required) + if out and not out[0]["content"]: + out[0]["content"] = "" # keep empty; template allows empty user + if out and not out[-1]["content"]: + pass + + # Debug log to inspect roles if needed + try: + role_seq = [m["role"] for m in out] + logger.debug(f"LMStudio normalized roles: {role_seq}") + except Exception: + pass + + return out + + +# =================================================================== +# Providers +# =================================================================== + +class OpenRouterProvider(LLMProvider): + def __init__(self): + self.api_key = settings.llm_providers.openrouter.api_key + if not self.api_key or "sk-or-" not in self.api_key: + raise ValueError("OpenRouter API key is missing or invalid in your .env file") + self.api_url = "https://openrouter.ai/api/v1/chat/completions" + self.model = settings.llm.story_model + + async def generate_completion_stream( + self, messages: List[Dict[str, str]] + ) -> AsyncGenerator[str, None]: + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + payload = { + "model": self.model, + "messages": messages, + "stream": True, + } + async with httpx.AsyncClient(timeout=120) as client: + try: + async with client.stream("POST", self.api_url, headers=headers, json=payload) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if line.startswith("data: "): + data_str = line[len("data: "):] + if data_str.strip() == "[DONE]": + break + try: + data = json.loads(data_str) + content = data.get("choices", [{}])[0].get("delta", {}).get("content") + if content: + yield content + except json.JSONDecodeError: + continue + except httpx.HTTPStatusError as e: + body = e.response.text + logger.error(f"HTTP error from OpenRouter: {e.response.status_code} - {body}") + yield "Error: Connection to OpenRouter failed." + except Exception: + logger.error("Could not get completion stream from OpenRouter.", exc_info=True) + yield "Error: A problem occurred while contacting OpenRouter." + + +class OllamaProvider(LLMProvider): + def __init__(self): + self.base_url = settings.llm_providers.ollama.base_url + self.api_url = f"{self.base_url.rstrip('/')}/api/chat" + self.model = settings.llm.story_model + + async def generate_completion_stream( + self, messages: List[Dict[str, str]] + ) -> AsyncGenerator[str, None]: + payload = { + "model": self.model, + "messages": messages, + "stream": True, + } + async with httpx.AsyncClient(timeout=120) as client: + try: + async with client.stream("POST", self.api_url, json=payload) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if line: + try: + data = json.loads(line) + content = data.get("message", {}).get("content") + if content: + yield content + except json.JSONDecodeError: + continue + except httpx.HTTPStatusError as e: + body = e.response.text + logger.error(f"HTTP error from Ollama: {e.response.status_code} - {body}") + yield "Error: Could not connect to the local Ollama instance." + except Exception: + logger.error("Could not get completion stream from Ollama.", exc_info=True) + yield "Error: A problem occurred while contacting Ollama." + + +class LMStudioProvider(LLMProvider): + def __init__(self): + self.base_url = settings.llm_providers.lmstudio.base_url + self.api_url = f"{self.base_url.rstrip('/')}/chat/completions" + self.model = settings.llm.story_model + + async def generate_completion_stream( + self, messages: List[Dict[str, str]] + ) -> AsyncGenerator[str, None]: + # Strict alternation required by your jinja template + safe_messages = _normalize_for_lmstudio(messages) + + payload = { + "model": self.model, + "messages": safe_messages, + "stream": True, + } + # Optional: log role sequence we actually send + try: + logger.debug(f"Sending to LM Studio roles: {[m['role'] for m in safe_messages]}") + except Exception: + pass + + async with httpx.AsyncClient(timeout=120) as client: + try: + async with client.stream("POST", self.api_url, json=payload) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if line.startswith("data: "): + data_str = line[len("data: "):] + if data_str.strip() == "[DONE]": + break + try: + data = json.loads(data_str) + content = data.get("choices", [{}])[0].get("delta", {}).get("content") + if content: + yield content + except json.JSONDecodeError: + continue + except httpx.HTTPStatusError as e: + body = e.response.text + logger.error(f"HTTP error from LM Studio: {e.response.status_code} - {body}") + yield "Error: Could not get a response from LM Studio." + except Exception: + logger.error("Could not get completion stream from LM Studio.", exc_info=True) + yield "Error: A problem occurred while contacting LM Studio." + + +def make_llm_provider() -> LLMProvider: + backend = settings.llm.backend.lower() + if backend == "openrouter": + logger.info("Using OpenRouter LLM provider.") + return OpenRouterProvider() + elif backend == "ollama": + logger.info("Using Ollama LLM provider.") + return OllamaProvider() + elif backend == "lmstudio": + logger.info("Using LM Studio LLM provider.") + return LMStudioProvider() + else: + raise ValueError(f"Unknown LLM backend specified in settings.yml: {backend}") diff --git a/server/logger.py b/server/logger.py new file mode 100644 index 0000000..62d3f8c --- /dev/null +++ b/server/logger.py @@ -0,0 +1,51 @@ +# server/logger.py +import logging +import sys +import warnings # <-- NEW IMPORT +from rich.logging import RichHandler + +# --- NEW: Suppress specific, harmless warnings from dependencies --- +# This keeps the console clean during startup. We are ignoring warnings that are +# internal to the 'torch' library and are not actionable by us. + +# 1. The 'dropout' warning is informational from PyTorch about the Kokoro model's architecture. +warnings.filterwarnings( + "ignore", + category=UserWarning, + message=r".*dropout option adds dropout after all but last recurrent layer.*" +) +# 2. The 'weight_norm' warning is a forward-compatibility notice from PyTorch. +warnings.filterwarnings( + "ignore", + category=FutureWarning, + message=r".*`torch.nn.utils.weight_norm` is deprecated.*" +) + +# 3. The 'pkg_ resources' warning. +warnings.filterwarnings( + "ignore", + category=UserWarning, + message=r".*pkg_resources is deprecated as an API.*" +) + +# --- The rest of the logger configuration is unchanged --- + +# Configure the RichHandler for beautiful console output +handler = RichHandler(show_time=False, rich_tracebacks=True, log_time_format="[%X]") + +# Define the format for our log messages +FORMAT = "%(message)s" +formatter = logging.Formatter(FORMAT) +handler.setFormatter(formatter) + +# Get the root logger and configure it +logger = logging.getLogger("vdm") +logger.setLevel(logging.INFO) + +# Add our rich handler +logger.addHandler(handler) + +# Prevent the log messages from being duplicated by the root logger +logger.propagate = False + +logger.info("Logging started") \ No newline at end of file diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..6dca95d --- /dev/null +++ b/server/main.py @@ -0,0 +1,525 @@ +# server/main.py +import base64 +import asyncio +from pathlib import Path +from typing import Dict, List, Set + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from starlette.websockets import WebSocketState + +from .config import settings +from .models import ( + Room, + WSIncomingMessage, + WSOutgoingMessage, + RegisterRequest, + Player, + LoginRequest, +) +from .room_manager import RoomManager +from .story_manager import StoryManager +from .audio_manager import AudioManager +from .game_manager import DiceRoller +from .logger import logger +from .user_manager import UserManager + +# =================================================================== +# Application Setup +# =================================================================== +BASE_DIR = Path(__file__).resolve().parent.parent +app = FastAPI(title="VDM - Virtual Dungeon Master") +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + 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") + + +class ConnectionManager: + """Manages active WebSocket connections for each room.""" + + def __init__(self): + self.connections: Dict[str, Set[WebSocket]] = {} + + async def connect(self, room_id: str, websocket: WebSocket): + await websocket.accept() + self.connections.setdefault(room_id, set()).add(websocket) + logger.info( + f"New connection in room '{room_id}'. Total: {len(self.connections[room_id])}" + ) + + def disconnect(self, room_id: str, websocket: WebSocket): + if room_id in self.connections: + self.connections[room_id].discard(websocket) + logger.info( + f"Disconnected from room '{room_id}'. Remaining: {len(self.connections.get(room_id, set()))}" + ) + + async def broadcast(self, room_id: str, message: WSOutgoingMessage): + if room_id not in self.connections: + return + payload = message.model_dump_json() + tasks = [ + connection.send_text(payload) + for connection in self.connections.get(room_id, set()) + if connection.client_state == WebSocketState.CONNECTED + ] + await asyncio.gather(*tasks) + + +# --- Instantiate Managers --- +user_manager = UserManager() +room_manager = RoomManager(user_manager=user_manager) +story_manager = StoryManager() +audio_manager = AudioManager() +game_manager = DiceRoller() +connection_manager = ConnectionManager() + + +# =================================================================== +# Core Game Loop Logic +# =================================================================== + + +async def _start_game_setup_turn(room_id: str): + """ + Handles the very first turn of the game (the GM's setup prompt), + respecting the streaming setting. + """ + room_manager.get_or_create_room(room_id) + + if settings.audio.enable_streaming: + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="stream_start", payload={}) + ) + + full_gm_response = "" + text_generator = story_manager.generate_gm_response_stream(room_id, []) + + async for text_chunk in text_generator: + if not text_chunk: + continue + full_gm_response += text_chunk + + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="chat_chunk", payload={"content": text_chunk}) + ) + + audio_generator = audio_manager.synthesize_stream(text_chunk) + async for audio_chunk in audio_generator: + encoded_chunk = base64.b64encode(audio_chunk).decode("utf-8") + await connection_manager.broadcast( + room_id, + WSOutgoingMessage(kind="audio_chunk", payload={"chunk": encoded_chunk}), + ) + + gm_message = room_manager.add_message( + room_id, "gm", "GM", full_gm_response.strip(), audio_url=None + ) + await connection_manager.broadcast( + room_id, + WSOutgoingMessage(kind="stream_end", payload={"final_message": gm_message.model_dump()}), + ) + else: + gm_prompt = await story_manager.generate_gm_response(room_id, []) + audio_url = await audio_manager.synthesize(gm_prompt) + gm_message = room_manager.add_message( + room_id, "gm", "GM", gm_prompt, audio_url=audio_url + ) + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="chat", payload=gm_message.model_dump()) + ) + if audio_url: + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="audio", payload={"url": audio_url}) + ) + + +async def _advance_turn(room_id: str, submitter: Player): + """ + Orchestrates the GM's turn, handling both streaming and non-streaming modes. + """ + room_state = room_manager.get_room(room_id) + if ( + not room_state + or room_state.turn_state == "GM_PROCESSING" + or not room_state.current_turn_actions + ): + return + + room_state.turn_state = "GM_PROCESSING" + await connection_manager.broadcast( + room_id, + WSOutgoingMessage( + kind="system", + payload={"message": f"{submitter.name} submitted the turn. The GM ponders..."}, + ), + ) + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="state_update", payload=room_state.model_dump()) + ) + + turn_actions = { + room_state.players[pid].name: action + for pid, action in room_state.current_turn_actions.items() + if pid in room_state.players + } + history = [msg.model_dump() for msg in room_state.messages] + consolidated_actions_text = "\n".join( + f"[{name}] {action}" for name, action in turn_actions.items() + ) + room_manager.add_message(room_id, "party", "Party Actions", consolidated_actions_text) + + if settings.audio.enable_streaming: + await _advance_turn_streaming(room_id, history, turn_actions) + else: + await _advance_turn_non_streaming(room_id, history, turn_actions) + + room_state.current_turn_actions.clear() + room_state.turn_state = "WAITING_FOR_ACTIONS" + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="state_update", payload=room_state.model_dump()) + ) + + +async def _advance_turn_streaming( + room_id: str, history: List[Dict], turn_actions: Dict[str, str] +): + """Handles the game turn with real-time streaming of text and audio.""" + logger.info(f"Advancing turn for room '{room_id}' with STREAMING.") + + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="stream_start", payload={}) + ) + + full_gm_response = "" + text_generator = story_manager.generate_gm_response_stream( + room_id, history, turn_actions + ) + + async for text_chunk in text_generator: + if not text_chunk: + continue + full_gm_response += text_chunk + + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="chat_chunk", payload={"content": text_chunk}) + ) + + audio_generator = audio_manager.synthesize_stream(text_chunk) + async for audio_chunk in audio_generator: + encoded_chunk = base64.b64encode(audio_chunk).decode("utf-8") + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="audio_chunk", payload={"chunk": encoded_chunk}) + ) + + gm_message = room_manager.add_message( + room_id, "gm", "GM", full_gm_response.strip(), audio_url=None + ) + await connection_manager.broadcast( + room_id, + WSOutgoingMessage(kind="stream_end", payload={"final_message": gm_message.model_dump()}), + ) + + +async def _advance_turn_non_streaming( + room_id: str, history: List[Dict], turn_actions: Dict[str, str] +): + """Handles the game turn by generating the full response before sending.""" + logger.info(f"Advancing turn for room '{room_id}' NON-STREAMING.") + + gm_response = await story_manager.generate_gm_response( + room_id, history, turn_actions + ) + audio_url = await audio_manager.synthesize(gm_response) + gm_message = room_manager.add_message( + room_id, "gm", "GM", gm_response, audio_url=audio_url + ) + + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="chat", payload=gm_message.model_dump()) + ) + if audio_url: + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="audio", payload={"url": audio_url}) + ) + + +async def _resume_game_turn(room_id: str, player: Player): + """Resumes a game non-streamed for simplicity.""" + room_state = room_manager.get_room(room_id) + if ( + not room_state + or player.id != room_state.host_player_id + or room_state.game_state != "PLAYING" + ): + return + + await connection_manager.broadcast( + room_id, + WSOutgoingMessage(kind="system", payload={"message": f"{player.name} is resuming the game..."}), + ) + room_state.turn_state = "GM_PROCESSING" + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="state_update", payload=room_state.model_dump()) + ) + + 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) + gm_message = room_manager.add_message( + room_id, "gm", "GM", gm_summary, audio_url=audio_url + ) + + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="chat", payload=gm_message.model_dump()) + ) + if audio_url: + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="audio", payload={"url": audio_url}) + ) + + room_state.turn_state = "WAITING_FOR_ACTIONS" + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="state_update", payload=room_state.model_dump()) + ) + + +# =================================================================== +# API & WebSocket Endpoints +# =================================================================== + + +@app.get("/") +async def get_root(): + return FileResponse(BASE_DIR / "web/index.html") + + +@app.post("/api/register") +async def register_player(request: RegisterRequest): + success, message = user_manager.register_player( + request.name, request.avatar_style, request.password + ) + if not success: + raise HTTPException(status_code=400, detail=message) + return JSONResponse(content={"message": message}) + + +@app.post("/api/login") +async def login_player(request: LoginRequest): + user_data = user_manager.login(request.name, request.password) + if not user_data: + raise HTTPException(status_code=401, detail="Invalid username or password.") + return JSONResponse(content=user_data) + + +@app.get("/api/voices") +async def get_voices(): + return JSONResponse(content=audio_manager.list_voices()) + + +@app.websocket("/ws/{room_id}/{player_id}/{player_token}") +async def websocket_endpoint( + websocket: WebSocket, room_id: str, player_id: str, player_token: str +): + add_player_result = room_manager.add_player(room_id, player_id, player_token) + if not add_player_result: + await websocket.close(code=4001, reason="Invalid session token.") + return + + await connection_manager.connect(room_id, websocket) + room, player = add_player_result + + 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}'.") + + await connection_manager.broadcast( + room_id, + WSOutgoingMessage(kind="system", payload={"message": f"{player.name} has joined the game!"}), + ) + await connection_manager.broadcast( + room_id, WSOutgoingMessage(kind="state_update", payload=room.model_dump()) + ) + + try: + while True: + data = await websocket.receive_text() + try: + msg = WSIncomingMessage.model_validate_json(data) + room_state = room_manager.get_room(room_id) + if not room_state: + continue + + if msg.kind == "start_game": + if ( + player.id == room_state.host_player_id + and room_state.game_state == "LOBBY" + ): + room_state.game_state = "PLAYING" + + await connection_manager.broadcast( + room_id, + WSOutgoingMessage( + kind="system", payload={"message": "The game is starting..."} + ), + ) + await _start_game_setup_turn(room_id) + + await connection_manager.broadcast( + room_id, + WSOutgoingMessage( + kind="state_update", payload=room_state.model_dump() + ), + ) + + elif msg.kind == "resume_game": + await _resume_game_turn(room_id, player) + + elif msg.kind == "submit_turn": + await _advance_turn(room_id, player) + + elif msg.kind == "say": + text = msg.payload.get("message", "").strip() + if not text: + continue + + is_command = text.startswith("/") + if is_command: + parts = text.split() + cmd = parts[0].lower() + if cmd == "/roll": + notation = parts[1] if len(parts) > 1 else "1d20" + result = game_manager.roll(notation) + if result: + roll_msg = room_manager.add_message( + room_id, + player.id, + player.name, + f"rolls {result.as_string}", + ) + await connection_manager.broadcast( + room_id, + WSOutgoingMessage( + kind="chat", + payload={ + **roll_msg.model_dump(), + "is_roll": True, + }, + ), + ) + elif cmd == "/save": + room_manager.save_room_state(room_id) + await connection_manager.broadcast( + room_id, + WSOutgoingMessage( + kind="system", + payload={ + "message": f"Game progress saved by {player.name}." + }, + ), + ) + elif cmd == "/remember": + memory_text = " ".join(parts[1:]) + if memory_text: + story_manager.memory_manager.add_memory( + room_id, memory_text + ) + await connection_manager.broadcast( + room_id, + WSOutgoingMessage( + kind="system", + payload={ + "message": f"{player.name} added a memory: '{memory_text[:50]}...'" + }, + ), + ) + elif cmd == "/next": + await _advance_turn(room_id, player) + elif cmd == "/ooc": + ooc_text = " ".join(parts[1:]) + if ooc_text: + ooc_msg = room_manager.add_message( + room_id, player.id, player.name, f"// {ooc_text}" + ) + await connection_manager.broadcast( + room_id, + WSOutgoingMessage( + kind="chat", + payload={ + **ooc_msg.model_dump(), + "is_ooc": True, + }, + ), + ) + else: + await websocket.send_text( + WSOutgoingMessage( + kind="system", + payload={"message": f"Unknown command: {cmd}"}, + ).model_dump_json() + ) + else: + if room_state.turn_state == "GM_PROCESSING": + continue + room_state.current_turn_actions[player.id] = text + action_msg = room_manager.add_message( + room_id, player.id, player.name, text + ) + await connection_manager.broadcast( + room_id, + WSOutgoingMessage( + kind="chat", payload=action_msg.model_dump() + ), + ) + await connection_manager.broadcast( + room_id, + WSOutgoingMessage( + kind="state_update", payload=room_state.model_dump() + ), + ) + except Exception: + logger.error(f"Error processing message from {player.name}", exc_info=True) + + except WebSocketDisconnect: + user_manager.logout(player_token) + connection_manager.disconnect(room_id, websocket) + disconnected_player = room_manager.remove_player(room_id, player_id) + if disconnected_player and (room_state := room_manager.get_room(room_id)): + room_state.current_turn_actions.pop(player_id, None) + if room_state.host_player_id == player_id: + new_host_id = next( + (pid for pid, p in room_state.players.items() if p.is_active), None + ) + room_state.host_player_id = new_host_id + if new_host_id: + new_host_name = room_state.players[new_host_id].name + await connection_manager.broadcast( + room_id, + WSOutgoingMessage( + kind="system", + payload={ + "message": f"The host has left. {new_host_name} is the new host." + }, + ), + ) + await connection_manager.broadcast( + room_id, + WSOutgoingMessage( + kind="system", + payload={"message": f"{disconnected_player.name} has left the game."}, + ), + ) + await connection_manager.broadcast( + room_id, + WSOutgoingMessage(kind="state_update", payload=room_state.model_dump()), + ) \ No newline at end of file diff --git a/server/memory_manager.py b/server/memory_manager.py new file mode 100644 index 0000000..55914f1 --- /dev/null +++ b/server/memory_manager.py @@ -0,0 +1,157 @@ +# server/memory_manager.py +from __future__ import annotations + +from pathlib import Path +from typing import List, Iterable, Dict, Any, Optional, TypedDict, cast +import re +import time +import uuid +import numpy as np + +import chromadb +from chromadb.config import Settings as ChromaSettings +from sentence_transformers import SentenceTransformer + +from .config import settings +from .logger import logger + +# ---------- Simple sentence splitting & chunking ---------- + +def _sentence_split(text: str) -> List[str]: + """Lightweight sentence splitter; avoids heavy deps.""" + t = re.sub(r"\s+", " ", text).strip() + if not t: + return [] + parts = re.split(r"(?<=[.!?])\s+", t) + return [p.strip() for p in parts if p.strip()] + + +def _chunk_sentences( + sentences: List[str], + max_chars: int = 700, + overlap: int = 1, +) -> List[str]: + """Pack sentences into ~max_chars chunks with small overlap for recall.""" + chunks: List[str] = [] + buf: List[str] = [] + cur = 0 + for s in sentences: + if cur + len(s) + (1 if buf else 0) > max_chars and buf: + chunks.append(" ".join(buf)) + buf = buf[-overlap:] if overlap > 0 else [] + cur = sum(len(x) for x in buf) + (len(buf) - 1 if buf else 0) + if cur > 0: + cur += 1 + buf.append(s) + cur += len(s) + if buf: + chunks.append(" ".join(buf)) + return chunks + + +# ---------- Embedding wrapper ---------- + +class _STEmbedder: + """Sentence-Transformers wrapper that returns numpy arrays with the right dtype.""" + def __init__(self, model_name: str) -> None: + self.model = SentenceTransformer(model_name, device="cpu") + logger.info(f"SentenceTransformer loaded: {model_name}") + + def embed(self, texts: Iterable[str]) -> np.ndarray: + arr = self.model.encode( + list(texts), + normalize_embeddings=True, + convert_to_numpy=True, + ) + if not isinstance(arr, np.ndarray): + arr = np.asarray(arr) + if arr.dtype != np.float32: + arr = arr.astype(np.float32, copy=False) + return arr + + +# ---------- Metadata shape (all primitives) ---------- + +Primitive = str | int | float | bool | None + +class MemoryMeta(TypedDict, total=False): + room_id: str + ts: int + len: int + + +# ---------- Memory manager ---------- + +class MemoryManager: + def __init__(self): + # FIX: Updated to use the new setting path from the reorganized config. + self.memory_dir = Path(settings.paths.memory_dir) + self.memory_dir.mkdir(parents=True, exist_ok=True) + + try: + chroma_settings = ChromaSettings(anonymized_telemetry=False) + self.chroma = chromadb.PersistentClient( + path=str(self.memory_dir / "chroma_db"), + settings=chroma_settings, + ) + logger.info("ChromaDB PersistentClient initialized (telemetry OFF).") + # FIX: Now uses the embedding model specified in the settings file. + self.embedder = _STEmbedder(settings.memory.embedding_model) + except Exception: + logger.critical("Failed to initialize MemoryManager.", exc_info=True) + raise + + def _collection_name(self, room_id: str) -> str: + return f"vdm_{room_id}" + + def _get_collection(self, room_id: str): + return self.chroma.get_or_create_collection( + name=self._collection_name(room_id), + metadata={"hnsw:space": "cosine"}, + ) + + def add_memory(self, room_id: str, text: str) -> None: + if not text or not text.strip(): + return + try: + sentences = _sentence_split(text) + if not sentences: return + chunks: List[str] = _chunk_sentences(sentences, max_chars=700, overlap=1) + if not chunks: return + + embeds_np: np.ndarray = self.embedder.embed(chunks) + col = self._get_collection(room_id) + + ts = int(time.time()) + ids: List[str] = [uuid.uuid4().hex for _ in chunks] + metadatas: List[Dict[str, Primitive]] = [ + {"room_id": room_id, "ts": ts, "len": len(c)} for c in chunks + ] + col.add( + ids=ids, + documents=chunks, + embeddings=cast(Any, embeds_np), + metadatas=cast(Any, metadatas), + ) + logger.info(f"Added {len(chunks)} memory chunk(s) to room '{room_id}'.") + except Exception: + logger.error(f"Failed to add memory to room '{room_id}'.", exc_info=True) + + def search_memory(self, room_id: str, query_text: str, k: int = 3) -> List[str]: + if not query_text or not query_text.strip(): + return [] + try: + col = self._get_collection(room_id) + if col.count() == 0: + return [] + + q_np: np.ndarray = self.embedder.embed([query_text]) + result = col.query( + query_embeddings=cast(Any, q_np), + n_results=max(1, k), + ) + docs = (result.get("documents") or [[]])[0] + return [d for d in docs if d] + except Exception: + logger.error(f"Failed to search memory for room '{room_id}'.", exc_info=True) + return [] \ No newline at end of file diff --git a/server/models.py b/server/models.py new file mode 100644 index 0000000..40ccac3 --- /dev/null +++ b/server/models.py @@ -0,0 +1,69 @@ +# server/models.py +from pydantic import BaseModel, Field +from typing import Dict, List, Literal, Any, Optional + +# =================================================================== +# Core Game & Application Models +# =================================================================== +# These Pydantic models define the structure of our application's state. + +class Player(BaseModel): + id: str + name: str + avatar_style: str = "adventurer" + is_active: bool = True + +class ChatMessage(BaseModel): + author_id: str + author_name: str + content: str + audio_url: Optional[str] = None + is_ooc: bool = False + +class Room(BaseModel): + room_id: str + players: Dict[str, Player] = Field(default_factory=dict) + messages: List[ChatMessage] = Field(default_factory=list) + turn_state: Literal["WAITING_FOR_ACTIONS", "GM_PROCESSING"] = "WAITING_FOR_ACTIONS" + current_turn_actions: Dict[str, str] = Field(default_factory=dict) + game_state: Literal["LOBBY", "PLAYING"] = "LOBBY" + host_player_id: Optional[str] = None + +class RegisterRequest(BaseModel): + name: str + avatar_style: str + password: str + +class LoginRequest(BaseModel): + 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. + +class WSIncomingMessage(BaseModel): + """A message received from a client.""" + kind: Literal[ + "say", + "start_game", + "submit_turn", + "resume_game" + ] + payload: Dict[str, Any] + +class WSOutgoingMessage(BaseModel): + """A message sent from the server to clients.""" + kind: Literal[ + "system", + "chat", + "audio", + "state_update", + "stream_start", + "chat_chunk", + "audio_chunk", + "stream_end" + ] + payload: Dict[str, Any] \ No newline at end of file diff --git a/server/persistence_manager.py b/server/persistence_manager.py new file mode 100644 index 0000000..a79e596 --- /dev/null +++ b/server/persistence_manager.py @@ -0,0 +1,43 @@ +# 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 + +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 save_room(self, room: Room) -> bool: + """ + Saves the complete state of a room to the database. + + Args: + room: The Room object to save. + + Returns: + True if saving was successful, False otherwise. + """ + logger.info(f"Saving session for room '{room.room_id}'...") + return self.db_manager.save_room(room) + + def load_room(self, room_id: str) -> Optional[Room]: + """ + Loads a room's state from the database if it exists. + + Args: + room_id: The ID of the room to load. + + Returns: + A Room object if a session was found and loaded successfully, otherwise None. + """ + logger.info(f"Attempting to load session for room '{room_id}'...") + return self.db_manager.load_room(room_id) \ No newline at end of file diff --git a/server/room_manager.py b/server/room_manager.py new file mode 100644 index 0000000..5718e41 --- /dev/null +++ b/server/room_manager.py @@ -0,0 +1,134 @@ +# server/room_manager.py +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 + +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__ + + self._rooms: Dict[str, Room] = {} + self._persistence_manager = PersistenceManager() + 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. + """ + if room_id in self._rooms: + return self._rooms[room_id] + + loaded_room = self._persistence_manager.load_room(room_id) + if loaded_room: + for player in loaded_room.players.values(): + player.is_active = False + self._rooms[room_id] = loaded_room + return loaded_room + + logger.info(f"Creating new room '{room_id}'") + new_room = Room(room_id=room_id) + self._rooms[room_id] = new_room + return new_room + + def save_room_state(self, room_id: str): + """A convenience method to trigger saving a room's state.""" + if room_id in self._rooms: + self._persistence_manager.save_room(self._rooms[room_id]) + else: + logger.warning(f"Attempted to save non-existent or inactive room: {room_id}") + + 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. + """ + player_data = self._user_manager.get_user_by_token(player_token) + if not player_data: + logger.warning(f"Player with invalid token tried to join room '{room_id}'.") + return None + + room = self.get_or_create_room(room_id) + player_name = player_data["name"] + + existing_player: Optional[Player] = None + old_player_id: Optional[str] = None + for pid, p in room.players.items(): + 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 + 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"], + avatar_style=player_data["avatar_style"], + is_active=True + ) + room.players[player_id] = new_player + return room, new_player + + def remove_player(self, room_id: str, player_id: str) -> Optional[Player]: + """ + Deactivates a player in a room, preserving their data. + """ + 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 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) + return player + return None + + def add_message( + self, + room_id: str, + author_id: str, + author_name: str, + content: str, + audio_url: Optional[str] = None + ) -> ChatMessage: + """ + Adds a chat message to a room's history, optionally with an audio URL. + """ + room = self.get_or_create_room(room_id) + message = ChatMessage( + author_id=author_id, + author_name=author_name, + content=content, + audio_url=audio_url + ) + room.messages.append(message) + return message + + def get_room(self, room_id: str) -> Optional[Room]: + """ + Safely retrieves an active room's state from memory. + """ + return self._rooms.get(room_id) \ No newline at end of file diff --git a/server/story_manager.py b/server/story_manager.py new file mode 100644 index 0000000..48b3b96 --- /dev/null +++ b/server/story_manager.py @@ -0,0 +1,262 @@ +# server/story_manager.py +from __future__ import annotations + +import re +import yaml +import json +from typing import List, Dict, Any, Optional, AsyncGenerator + +from .config import settings +from .llm_providers import LLMProvider, make_llm_provider +from .logger import logger +from .memory_manager import MemoryManager + +# ============================================================================== +# Prompt Loading & Dynamic Construction +# ============================================================================== +def load_prompts_from_yaml(path: str) -> Dict[str, Any]: + try: + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + except FileNotFoundError: + logger.critical(f"Prompts YAML file not found at: {path}") + exit(1) + except Exception as e: + logger.critical(f"Failed to load or parse prompts YAML file: {e}", exc_info=True) + exit(1) + +PROMPTS = load_prompts_from_yaml(settings.paths.prompts_file) + +VDM_SETUP_PROMPT = PROMPTS.get("setup", "") +VDM_RESUME_PROMPT = PROMPTS.get("resume_game", "") + +_gameplay_prompts = PROMPTS.get("gameplay", {}) +_BASE_INSTRUCTION = _gameplay_prompts.get("base", "") +_JSON_INPUT_INSTRUCTION = _gameplay_prompts.get("json_input_instruction", "") +_LEGACY_TEXT_INSTRUCTION = _gameplay_prompts.get("legacy_text_input_instruction", "") +_VOICE_TAGGING_INSTRUCTION = _gameplay_prompts.get("voice_tagging_instruction", "") +_REASONING_TAG_INSTRUCTION = _gameplay_prompts.get("tagging_instruction", "") + +def build_system_prompt() -> str: + parts = [_BASE_INSTRUCTION] + if settings.llm.prompting_strategy == "json": + parts.append(_JSON_INPUT_INSTRUCTION) + else: + parts.append(_LEGACY_TEXT_INSTRUCTION) + if settings.audio.enable_dynamic_casting: + parts.append(_VOICE_TAGGING_INSTRUCTION) + if settings.llm.llm_uses_tags: + parts.append(_REASONING_TAG_INSTRUCTION) + return "\n\n".join(p for p in parts if p) + +VDM_SYSTEM_PROMPT = build_system_prompt() +logger.info("VDM System Prompt constructed successfully.") + +class StoryManager: + def __init__(self): + self.provider: LLMProvider = make_llm_provider() + self.memory_manager = MemoryManager() + + # -------------------------- Parsing helpers -------------------------- + def _parse_llm_output(self, raw_text: str) -> str: + if not settings.llm.llm_uses_tags: + return raw_text.strip() + match = re.search(r'(.*)', raw_text, re.DOTALL) + if match: + return match.group(1).strip() + logger.warning("llm_uses_tags is true, but could not find . Using raw text.") + return raw_text.replace("", "").replace("", "").strip() + + def _parse_player_input(self, text: str) -> Dict[str, str]: + dialogue_parts = re.findall(r'["“](.*?)["”]', text) + dialogue = " ".join(dialogue_parts).strip() + action = re.sub(r'["“](.*?)["”]', '', text).strip() + if not action and dialogue: + action = "Says..." + elif not action and not dialogue: + action = "..." + return {"action": action, "dialogue": dialogue} + + # ---------------------- Message shaping helpers ---------------------- + @staticmethod + def _coalesce_same_role(messages: List[Dict[str, str]]) -> List[Dict[str, str]]: + if not messages: + return [] + out: List[Dict[str, str]] = [] + for m in messages: + role = m.get("role") + content = m.get("content", "") + if out and out[-1]["role"] == role: + out[-1]["content"] = (out[-1]["content"] + ("\n\n" if out[-1]["content"] else "") + content).strip() + elif role: + out.append({"role": role, "content": content}) + return out + + def _prepare_turn_actions_block(self, turn_actions: Dict[str, str]) -> Dict[str, str]: + final_instruction = "Based on the above inputs and any relevant memories, generate the next part of the story." + if settings.llm.prompting_strategy == "json": + structured_actions = [] + for player_name, action_text in turn_actions.items(): + parsed = self._parse_player_input(action_text) + structured_actions.append({ + "player_name": player_name, + "action": parsed["action"], + "dialogue": parsed["dialogue"] + }) + actions_json = json.dumps(structured_actions, indent=2) + content = ( + "Here are the player inputs for the current turn:\n" + f"```json\n{actions_json}\n```\n\n{final_instruction}" + ) + else: + action_lines = [f"[{name}]: {action}" for name, action in turn_actions.items()] + consolidated_actions = "\n".join(action_lines) + content = ( + "Here are the actions for the current turn:\n" + f"{consolidated_actions}\n\n{final_instruction}" + ) + return {"role": "user", "content": content} + + def _prepare_messages( + self, + room_id: str, + chat_history: List[Dict[str, Any]], + turn_actions: Dict[str, str] + ) -> List[Dict[str, str]]: + # Light memory retrieval + query_text = " ".join([msg.get('content', '') for msg in chat_history[-5:]] + list(turn_actions.values())) + memories = self.memory_manager.search_memory(room_id, query_text) + memory_context = "" + if memories: + memory_list = "\n".join(f"- {m}" for m in memories) + memory_context = f"\n\nHere are some relevant memories from the past:\n{memory_list}" + + system_prompt = f"{VDM_SYSTEM_PROMPT}{memory_context}" + messages: List[Dict[str, str]] = [{"role": "system", "content": system_prompt}] + + # Pull in recent chat history as alternating user/assistant + recent_history = chat_history[-settings.llm.context_messages:] + for message in recent_history: + role = 'assistant' if message.get('author_id') == 'gm' else 'user' + author_id = message.get('author_id') + + # Avoid duplicating the same player inputs when we add the consolidated block + if (role == 'user' and author_id in turn_actions) or author_id == 'party': + continue + + content = message.get('content', '') + if role == 'user': + author_name = message.get('author_name', 'Player') + content = f"[{author_name}]: {content}" + if content.strip(): + messages.append({"role": role, "content": content}) + + # Consolidated actions for this turn (as a single user block) + if turn_actions: + messages.append(self._prepare_turn_actions_block(turn_actions)) + + # Only coalesce; do NOT force-add a trailing user (LM Studio will add model turn itself) + messages = self._coalesce_same_role(messages) + return messages + + # ----------------------------- Public API ---------------------------- + async def generate_gm_response( + self, + room_id: str, + chat_history: List[Dict[str, Any]], + turn_actions: Optional[Dict[str, str]] = None + ) -> str: + if not chat_history: + messages = [ + {"role": "system", "content": VDM_SETUP_PROMPT}, + {"role": "user", "content": "Begin the game by greeting the players and asking about the setting."} + ] + messages = self._coalesce_same_role(messages) + raw_response = await self.provider.generate_completion_non_stream(messages) + return self._parse_llm_output(raw_response) + + if len(chat_history) == 1 and chat_history[0].get('author_id') != 'gm': + player_idea = f"[{chat_history[0].get('author_name', 'Player')}]: {chat_history[0].get('content', '')}" + messages = [ + {"role": "system", "content": VDM_SYSTEM_PROMPT}, + {"role": "user", "content": f"The players have decided on the following setting: {player_idea}. Generate a compelling opening scene and ask what they do."} + ] + messages = self._coalesce_same_role(messages) + raw_response = await self.provider.generate_completion_non_stream(messages) + parsed_response = self._parse_llm_output(raw_response) + self.memory_manager.add_memory(room_id, f"The game's setting is: {chat_history[0].get('content', '')}") + return parsed_response + + active_turn_actions = turn_actions or {} + messages = self._prepare_messages(room_id, chat_history, active_turn_actions) + raw_response = await self.provider.generate_completion_non_stream(messages) + parsed_response = self._parse_llm_output(raw_response) + + if parsed_response and active_turn_actions: + turn_summary = "Players did: " + ". ".join(active_turn_actions.values()) + ". Result: " + parsed_response + self.memory_manager.add_memory(room_id, turn_summary) + return parsed_response + + async def generate_gm_response_stream( + self, + room_id: str, + chat_history: List[Dict[str, Any]], + turn_actions: Optional[Dict[str, str]] = None + ) -> AsyncGenerator[str, None]: + if not chat_history: + messages = [ + {"role": "system", "content": VDM_SETUP_PROMPT}, + {"role": "user", "content": "Begin the game by greeting the players and asking about the setting."} + ] + elif len(chat_history) == 1 and chat_history[0].get('author_id') != 'gm': + player_idea = f"[{chat_history[0].get('author_name', 'Player')}]: {chat_history[0].get('content', '')}" + messages = [ + {"role": "system", "content": VDM_SYSTEM_PROMPT}, + {"role": "user", "content": f"The players have decided on the following setting: {player_idea}. Generate a compelling opening scene and ask what they do."} + ] + self.memory_manager.add_memory(room_id, f"The game's setting is: {chat_history[0].get('content', '')}") + else: + active_turn_actions = turn_actions or {} + messages = self._prepare_messages(room_id, chat_history, active_turn_actions) + + # Coalesce only + messages = self._coalesce_same_role(messages) + + full_response = "" + async for chunk in self.provider.generate_completion_stream(messages): + yield chunk + full_response += chunk + + if turn_actions and full_response.strip(): + turn_summary = "Players did: " + ". ".join(turn_actions.values()) + ". Result: " + full_response.strip() + self.memory_manager.add_memory(room_id, turn_summary) + + async def generate_resume_summary(self, room_id: str, chat_history: List[Dict[str, Any]]) -> str: + logger.info(f"Generating resume summary for room '{room_id}'...") + if not chat_history: + return "There is no game history to resume from." + + query_text = " ".join([msg.get('content', '') for msg in chat_history[-10:]]) + memories = self.memory_manager.search_memory(room_id, query_text, k=5) + memory_context = "" + if memories: + memory_list = "\n".join(f"- {m}" for m in memories) + memory_context = f"\n\nHere are some relevant memories from the past:\n{memory_list}" + + messages: List[Dict[str, str]] = [ + {"role": "system", "content": f"{VDM_RESUME_PROMPT}{memory_context}"} + ] + recent_history = chat_history[-settings.llm.context_messages:] + for message in recent_history: + role = 'assistant' if message.get('author_id') == 'gm' else 'user' + content = f"[{message.get('author_name', 'Player')}]: {message.get('content', '')}" + messages.append({"role": role, "content": content}) + + messages.append({ + "role": "user", + "content": "Based on the memories and recent chat history, provide a summary and ask what we do next." + }) + + messages = self._coalesce_same_role(messages) + raw_response = await self.provider.generate_completion_non_stream(messages) + return self._parse_llm_output(raw_response) diff --git a/server/user_manager.py b/server/user_manager.py new file mode 100644 index 0000000..f4cef65 --- /dev/null +++ b/server/user_manager.py @@ -0,0 +1,150 @@ +# server/user_manager.py +import json +import uuid +from pathlib import Path +from typing import Dict, Optional, Tuple, TypedDict + +from passlib.context import CryptContext + +from .config import settings +from .logger import logger + +class StoredUserData(TypedDict): + name: str + avatar_style: str + hashed_password: str + +class ClientUserData(TypedDict): + token: str + name: str + avatar_style: str + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +class UserManager: + """ + Manages player registration and authentication using a simple JSON file. + - Stores users with hashed passwords for persistence. + - Issues temporary 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] = {} + 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) + + def _get_password_hash(self, password: str) -> str: + return pwd_context.hash(password) + + def _verify_password(self, plain_password: str, hashed_password: str) -> bool: + 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). + """ + 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: + 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." + + 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. + """ + user = self._users_by_name.get(name.lower()) + if not user: + return None + + if not self._verify_password(password, user["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.") + + client_data: ClientUserData = { + "name": user["name"], + "avatar_style": user["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.""" + username = self._sessions.get(token) + if not username: + return None + + return self._users_by_name.get(username.lower()) + + def logout(self, token: str): + """Removes a session token, effectively logging the user out.""" + if token in self._sessions: + del self._sessions[token] + logger.info(f"Session token {token[:8]}... ended.") \ No newline at end of file diff --git a/settings.yml b/settings.yml new file mode 100644 index 0000000..19a0340 --- /dev/null +++ b/settings.yml @@ -0,0 +1,87 @@ +# ============================================================================== +# VDM - VIRTUAL DUNGEON MASTER +# +# Application Settings +# +# This file controls the core behavior of the application. +# For secrets and environment-specific values (API keys, ports), use .env +# ============================================================================== + + +# === SECTION: Core AI & Narrative ============================================= +# Controls the behavior of the Large Language Model (LLM) as the storyteller. +# ------------------------------------------------------------------------------ +llm: + # The primary AI service to use for generating story content. + # Make sure the corresponding URL/API Key is set in your .env file. + # Options: "lmstudio", "ollama", "openrouter" + backend: "lmstudio" + + # The model identifier for the chosen backend. + # Example for OpenRouter: "google/gemma-2-9b-it" + # Example for LM Studio: "gemma-2-9b-it-gguf" (or whatever you have loaded) + story_model: "google/gemma-3n-e4b" + + # The format for sending player actions to the AI. + # "json": (Recommended) More reliable and less ambiguous for modern models. + # "legacy_text": A simpler text format for older or less capable models. + prompting_strategy: "json" + + # Set to 'true' if your model is specifically trained to use and + # tags. For most models, this should be 'false'. + llm_uses_tags: false + + # The maximum number of recent messages to include in the context sent to the AI. + # A larger number provides more short-term context but increases processing load. + context_messages: 20 + + +# === SECTION: Audio & Streaming =============================================== +# Configures the text-to-speech (TTS), voice casting, and streaming behavior. +# ------------------------------------------------------------------------------ +audio: + # Master switch for the real-time streaming of text and audio. + # true: Responds instantly, but is more CPU-intensive. May cause audio stutter + # on less powerful machines. + # false: Waits for the full response before playing audio. Less demanding. + enable_streaming: false + + # Master switch for the Dynamic Voice Casting feature (using RVC). + # If enabled, the AI can use different voices for different characters + # based on the voice casting file below. Requires RVC models. + enable_dynamic_casting: false + + # The default Kokoro voice used for narration if dynamic casting is off, + # or as a fallback voice for unassigned characters. + default_voice: "af_heart" + + +# === SECTION: Memory & Persistence ============================================ +# Configures the AI's long-term memory and how game sessions are saved. +# ------------------------------------------------------------------------------ +memory: + # The SentenceTransformer model used to create embeddings for the AI's + # 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" + + +# === SECTION: Resource Paths ================================================== +# Defines where the application should find its configuration and save output. +# ------------------------------------------------------------------------------ +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" + + # Directory where the long-term memory vector database (ChromaDB) is stored. + memory_dir: "./memory" + + # Directory where generated TTS audio files are saved. + audio_out_dir: "./audio_out" \ No newline at end of file diff --git a/voices.yml b/voices.yml new file mode 100644 index 0000000..fa81ccf --- /dev/null +++ b/voices.yml @@ -0,0 +1,36 @@ +# =================================================================== +# VDM - Voice Casting Configuration +# =================================================================== +# This file maps character names to specific TTS or RVC voices. + +# --- Default Voices --- +# These are used for narration or as fallbacks for unassigned characters. +defaults: + # The primary voice for narration (non-dialogue text). + narrator: "af_heart" + + # A fallback male voice if needed (future feature). + male: "am_michael" + +# --- Character Voice Casting --- +# Map specific character names to voices. The name is case-insensitive. +characters: + + # --- RVC EXAMPLE: A custom voice model --- + # To use this, you must have the specified .onnx and .index files. + Gandalf: + # The initial TTS voice used before conversion. Choose one that matches the pitch. + base_voice: "am_puck" + + # Paths to your RVC model files. + rvc_model: "./models/rvc/gandalf.onnx" + rvc_index: "./models/rvc/gandalf.index" + + # --- KOKORO EXAMPLE: A specific default voice --- + # This character will simply use a different Kokoro voice, no RVC. + Aragorn: + kokoro_voice: "am_fenrir" + + # --- KOKORO EXAMPLE 2 --- + Galadriel: + kokoro_voice: "af_bella" \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..1415f87 --- /dev/null +++ b/web/index.html @@ -0,0 +1,120 @@ + + + + + + VDM - Virtual Dungeon Master + + + + + + + + + +
+
VDM
+ +
+ +
+ + + + +
+ + +
+
+
Welcome to VDM! Please log in or register to start.
+
+ +
+
+ + + + +
+
+
+
+ + + \ No newline at end of file diff --git a/web/js/api.js b/web/js/api.js new file mode 100644 index 0000000..8c6703f --- /dev/null +++ b/web/js/api.js @@ -0,0 +1,195 @@ +// web/js/api.js + +/** + * @typedef {import('./state.js').AppState} AppState + * @typedef {import('./ui.js').AppUI} AppUI + */ + +/** + * Initializes and returns the API module. + * This module handles all communication with the backend server. + * @param {AppState} state - The central state object. + * @param {AppUI} ui - The UI module instance. + * @returns {object} The API module with methods for server interaction. + */ +export function initApi(state, ui) { + let ws = null; // Private WebSocket instance + + /** + * Handles incoming WebSocket messages and updates the state/UI accordingly. + * @param {MessageEvent} event - The WebSocket message event. + */ + function handleWsMessage(event) { + try { + const msg = JSON.parse(event.data); + + switch (msg.kind) { + case 'system': + ui.logMessage('system', msg.payload); + break; + case 'state_update': + ui.updateRoomState(msg.payload); + break; + case 'chat': // Non-streamed complete message + ui.logMessage('chat', msg.payload); + break; + case 'audio': // Non-streamed complete audio file + ui.playAudioFile(msg.payload.url); + break; + + // --- Streaming Handlers --- + case 'stream_start': + ui.handleStreamStart(); + break; + case 'chat_chunk': + ui.handleChatChunk(msg.payload.content); + break; + + case 'audio_chunk': { + // Chunks arrive as base64, so we must decode them. + const binaryString = atob(msg.payload.chunk); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + // The audio module (via UI) will handle the decoded chunk + ui.handleAudioChunk(bytes.buffer); + break; + } + + case 'stream_end': + ui.handleStreamEnd(msg.payload.final_message); + break; + } + } catch (error) { + console.error("Error processing WebSocket message:", error); + } + } + + const api = { + /** + * Attempts to register a new user. + * @param {string} name + * @param {string} avatar_style + * @param {string} password + * @returns {Promise<{success: boolean, message: string}>} + */ + async register(name, avatar_style, password) { + try { + const response = await fetch('/api/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, avatar_style, password }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || 'Registration failed.'); + } + return { success: true, message: data.message }; + } catch (error) { + return { success: false, message: error.message }; + } + }, + + /** + * Attempts to log in a user. + * @param {string} name + * @param {string} password + * @returns {Promise<{success: boolean, data?: any, message?: string}>} + */ + async login(name, password) { + try { + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, password }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.detail || 'Login failed.'); + } + return { success: true, data: data }; + } catch (error) { + return { success: false, message: error.message }; + } + }, + + /** + * Connects to the WebSocket server for a given room. + * @param {string} roomId + */ + connectToRoom(roomId) { + if (ws || !state.playerInfo) return; + + const { token } = state.playerInfo; + const proto = window.location.protocol === "https:" ? "wss" : "ws"; + const wsURL = `${proto}://${window.location.host}/ws/${roomId}/${state.clientId}/${token}`; + + ws = new WebSocket(wsURL); + + ws.onopen = () => { + state.isConnected = true; + ui.showRoomView(roomId); + console.log(`WebSocket connected to room: ${roomId}`); + }; + + ws.onmessage = handleWsMessage; + + ws.onclose = (event) => { + if(event.code === 4001) { + // Specific auth error from the server + alert("Session is invalid, please log in again."); + this.logout(); // The logout function will handle cleanup + } else { + ui.logMessage('system', { message: 'Disconnected from the room.' }); + } + this.disconnect(); + }; + + ws.onerror = (error) => { + console.error('WebSocket error:', error); + ui.logMessage('system', { message: 'A connection error occurred.' }); + this.disconnect(); + }; + }, + + /** + * Disconnects from the WebSocket server and cleans up the state. + */ + disconnect() { + if (ws) { + ws.close(); + ws = null; + } + state.isConnected = false; + state.room = null; + ui.showConnectionView(); + }, + + /** + * Logs the user out by clearing their saved data and disconnecting. + */ + logout() { + localStorage.removeItem('vdm-player'); + state.playerInfo = null; + this.disconnect(); // This will also update the UI + ui.showLoginView(); + }, + + /** + * Sends a message over the WebSocket. + * @param {string} kind - The message kind (e.g., 'say', 'submit_turn'). + * @param {object} payload - The message payload. + */ + sendMessage(kind, payload = {}) { + if (!ws || ws.readyState !== WebSocket.OPEN) { + console.warn("Attempted to send message while disconnected."); + return; + } + ws.send(JSON.stringify({ kind, payload })); + } + }; + + return api; +} \ No newline at end of file diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..7982f60 --- /dev/null +++ b/web/js/app.js @@ -0,0 +1,53 @@ +// web/js/app.js + +// Import the core modules of our application. +// We will create these files in the upcoming steps. +import { initState, state } from './state.js'; +import { initApi } from './api.js'; +import { initUI } from './ui.js'; + +/** + * @typedef {import('./state.js').AppState} AppState + */ + +/** + * Main application class. + * This class orchestrates the different modules of the application. + * @property {AppState} state - The reactive state object. + */ +class VDMApp { + constructor() { + this.state = state; + this.api = null; + this.ui = null; + } + + /** + * Initializes the entire application. + * This is the main entry point called when the DOM is ready. + */ + init() { + // 1. Initialize the central state management. + initState(); + + // 2. Initialize the UI module, passing it the state object. + // The UI will be responsible for all DOM manipulations. + this.ui = initUI(this.state); + + // 3. Initialize the API module, passing it the state and UI. + // The API module will handle all server communication. + this.api = initApi(this.state, this.ui); + + // Pass the api module to the UI so that UI elements can trigger API calls. + this.ui.setApi(this.api); + + console.log("VDM Frontend Initialized (Modular)."); + } +} + +// --- Application Entry Point --- +// When the DOM is fully loaded, create an instance of our app and initialize it. +document.addEventListener('DOMContentLoaded', () => { + const app = new VDMApp(); + app.init(); +}); \ No newline at end of file diff --git a/web/js/audio.js b/web/js/audio.js new file mode 100644 index 0000000..6ed7303 --- /dev/null +++ b/web/js/audio.js @@ -0,0 +1,191 @@ +// web/js/audio.js + +/** + * @typedef {import('./state.js').AppState} AppState + */ + +const SAMPLE_RATE = 24000; // Kokoro's native sample rate + +/** + * Initializes and returns the Audio module. + * This module manages all audio playback, including streaming chunks. + * @param {AppState} state - The central state object. + * @returns {object} The Audio module with methods for audio control. + */ +export function initAudio(state) { + let currentFullAudio = null; // For non-streaming audio files + let streamSourceNode = null; // For streaming audio: stores the current AudioBufferSourceNode + let streamStartTime = 0; // When the current stream segment started playing + let streamOffset = 0; // How much of the current segment has played + let bufferPromise = Promise.resolve(); // Chain promises for sequential playback + + /** + * Resumes the AudioContext if it's suspended. + * This needs to be called after a user gesture. + */ + function _resumeAudioContext() { + if (state.audioContext && state.audioContext.state === 'suspended') { + state.audioContext.resume().then(() => { + console.log("AudioContext resumed successfully."); + }).catch(e => console.error("Error resuming AudioContext:", e)); + } + } + + /** + * Plays a single AudioBuffer, chaining it to the promise queue. + * @param {AudioBuffer} buffer - The audio buffer to play. + */ + function _playAudioBuffer(buffer) { + // Chain this playback onto the existing promise queue + bufferPromise = bufferPromise.then(() => new Promise(resolve => { + if (!state.audioContext) { + console.error("AudioContext not available."); + state.isPlayingAudio = false; + return resolve(); + } + + // Resume context if needed before playing + _resumeAudioContext(); + + streamSourceNode = state.audioContext.createBufferSource(); + streamSourceNode.buffer = buffer; + streamSourceNode.connect(state.audioContext.destination); + + streamSourceNode.onended = () => { + // When this buffer finishes, resolve the promise for the next chunk + streamSourceNode = null; + resolve(); + }; + + streamSourceNode.start(0); // Play immediately + })).catch(e => { + console.error("Error playing audio buffer:", e); + state.isPlayingAudio = false; + // Ensure the promise chain continues even if one fails + return Promise.resolve(); + }); + } + + /** + * Processes the audio queue and plays chunks if not already playing. + */ + function _processAudioQueue() { + if (!state.isPlayingAudio && state.audioQueue.length > 0) { + state.isPlayingAudio = true; + _playNextQueuedChunk(); + } + } + + /** + * Plays the next chunk from the audio queue. + * This function calls itself recursively until the queue is empty. + */ + function _playNextQueuedChunk() { + if (state.audioQueue.length > 0 && state.audioContext) { + const rawChunk = state.audioQueue.shift(); // Get the next raw chunk (ArrayBuffer) + + // Create an AudioBuffer from the raw float32 PCM data + // The server sends float32, which can be directly loaded into an AudioBuffer. + const audioBuffer = state.audioContext.createBuffer( + 1, // mono + rawChunk.byteLength / Float32Array.BYTES_PER_ELEMENT, // length in samples + SAMPLE_RATE // sample rate + ); + + // Copy the raw float32 data into the AudioBuffer + const channelData = audioBuffer.getChannelData(0); + new Float32Array(rawChunk).forEach((value, index) => { + channelData[index] = value; + }); + + _playAudioBuffer(audioBuffer); // Play this chunk + + // After the current buffer finishes, play the next one + bufferPromise.finally(() => { + // If there are more chunks, continue playing + if (state.audioQueue.length > 0) { + _playNextQueuedChunk(); + } else { + state.isPlayingAudio = false; + console.log("Audio stream finished."); + } + }); + + } else { + state.isPlayingAudio = false; + } + } + + + const audioModule = { + /** + * Clears any active audio and prepares for a new stream. + */ + startStream() { + if (currentFullAudio) { + currentFullAudio.pause(); + currentFullAudio = null; + } + if (streamSourceNode) { + streamSourceNode.stop(); + streamSourceNode = null; + } + state.audioQueue = []; + state.isPlayingAudio = false; + bufferPromise = Promise.resolve(); // Reset the promise chain + }, + + /** + * Adds an audio chunk to the queue and triggers playback if needed. + * @param {ArrayBuffer} chunk - A raw ArrayBuffer of float32 PCM audio data. + */ + queueAndPlay(chunk) { + state.audioQueue.push(chunk); + _processAudioQueue(); + }, + + /** + * Signals the end of the current audio stream. + * Any remaining queued chunks will still play out. + */ + endStream() { + // No explicit action needed here, _processAudioQueue handles depletion. + // We just ensure the `isPlayingAudio` flag is managed correctly. + if (state.audioQueue.length === 0) { + state.isPlayingAudio = false; + } + }, + + /** + * Plays a full audio file from a URL (for non-streaming fallback or RVC). + * @param {string} url - The URL of the audio file. + */ + playFullAudioFile(url) { + this.startStream(); // Clear any existing stream or audio + if (url) { + currentFullAudio = new Audio(url); + _resumeAudioContext(); // Ensure context is active before playing + currentFullAudio.play().catch(e => console.warn("Audio autoplay was blocked or failed for URL:", url, e)); + } + }, + + /** + * Stops any currently playing audio. + */ + stopAudio() { + if (currentFullAudio) { + currentFullAudio.pause(); + currentFullAudio = null; + } + if (streamSourceNode) { + streamSourceNode.stop(); + streamSourceNode = null; + } + state.audioQueue = []; + state.isPlayingAudio = false; + bufferPromise = Promise.resolve(); + } + }; + + return audioModule; +} \ No newline at end of file diff --git a/web/js/state.js b/web/js/state.js new file mode 100644 index 0000000..e7b0125 --- /dev/null +++ b/web/js/state.js @@ -0,0 +1,81 @@ +// web/js/state.js + +/** + * @typedef {object} PlayerInfo - Information about the currently logged-in player. + * @property {string} name - The player's name. + * @property {string} avatar_style - The selected DiceBear avatar style. + * @property {string} token - The current session token. + */ + +/** + * @typedef {object} AppState - The central state object for the entire application. + * @property {string} clientId - A unique ID for this browser session. + * @property {PlayerInfo | null} playerInfo - Data for the logged-in player, or null if logged out. + * @property {any | null} room - The full room state object received from the server. + * @property {boolean} isConnected - True if the WebSocket is currently connected. + * @property {string} uiView - The current view of the application (e.g., 'login', 'register', 'chat'). + * @property {any | null} activeStream - Holds data related to the current active stream, if any. + * @property {string} selectedAvatarStyle - The avatar style selected during registration. + * @property {AudioContext | null} audioContext - The browser's audio context for streaming playback. + * @property {ArrayBuffer[]} audioQueue - A queue for incoming audio chunks. + * @property {boolean} isPlayingAudio - A flag to manage the audio playback loop. + */ + +/** + * The single, central state object for the application. + * @type {AppState} + */ +export const state = { + clientId: `player-${'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => ((Math.random()*16)|0).toString(16))}`, + playerInfo: null, + room: null, + isConnected: false, + uiView: 'login', // Default view is now login + activeStream: null, + selectedAvatarStyle: 'adventurer', // A default style + audioContext: null, + audioQueue: [], + isPlayingAudio: false, +}; + +/** + * Initializes the application state. + * This function should be called once when the application starts. + * It loads any persisted user data from localStorage. + */ +export function initState() { + // Attempt to load player information from localStorage. + const savedPlayer = localStorage.getItem('vdm-player'); + if (savedPlayer) { + try { + const playerData = JSON.parse(savedPlayer); + // Basic validation to ensure the loaded data has what we need. + if (playerData.name && playerData.token && playerData.avatar_style) { + state.playerInfo = playerData; + // If we have player info, the user is "logged in", + // so we should show them the connection/chat screen. + state.uiView = 'chat'; + } else { + // If data is malformed, clear it. + localStorage.removeItem('vdm-player'); + } + } catch (e) { + console.error("Failed to parse saved player data.", e); + localStorage.removeItem('vdm-player'); + } + } + + // Initialize the Web Audio API context. + // It must be created or resumed after a user interaction (like a button click), + // which we will handle in the UI module. + try { + state.audioContext = new (window.AudioContext || window.webkitAudioContext)(); + // Start in a suspended state until the user interacts. + if (state.audioContext.state === 'suspended') { + console.log("AudioContext is suspended. Will resume on user interaction."); + } + } catch (e) { + console.error("Web Audio API is not supported in this browser.", e); + // The app can continue without audio streaming. + } +} \ No newline at end of file diff --git a/web/js/ui.js b/web/js/ui.js new file mode 100644 index 0000000..be07799 --- /dev/null +++ b/web/js/ui.js @@ -0,0 +1,438 @@ +// web/js/ui.js +import { initAudio } from './audio.js'; + +/** + * @typedef {import('./state.js').AppState} AppState + * @typedef {import('./api.js').initApi} ApiModule + */ + +/** + * @typedef {object} AppUI - The public interface of the UI module. + * @property {(api: ReturnType) => void} setApi + * @property {(type: string, data: any) => void} logMessage + * @property {(room: any) => void} updateRoomState + * @property {(url: string) => void} playAudioFile + * @property {() => void} handleStreamStart + * @property {(content: string) => void} handleChatChunk + * @property {(chunk: ArrayBuffer) => void} handleAudioChunk + * @property {(finalMessage: any) => void} handleStreamEnd + * @property {(roomId: string) => void} showRoomView + * @property {() => void} showConnectionView + * @property {() => void} showLoginView + */ + +// List of available avatar styles. +const AVATAR_STYLES = [ + "adventurer", "adventurer-neutral", "avataaars", "big-ears", "big-smile", + "bottts", "croodles", "fun-emoji", "icons", "identicon", "initials", + "lorelei", "micah", "miniavs", "open-peeps", "personas", "pixel-art", "rings" +]; + +/** + * Initializes and returns the UI module. + * @param {AppState} state - The central state object. + * @returns {AppUI} + */ +export function initUI(state) { + // A cache for all DOM elements we will interact with. + const dom = { + // Auth Forms & Views + loginView: document.getElementById('login-view'), + registerView: document.getElementById('register-view'), + loginForm: document.getElementById('login-form'), + registerForm: document.getElementById('register-form'), + loginNameInput: document.getElementById('login-name'), + loginPasswordInput: document.getElementById('login-password'), + registerNameInput: document.getElementById('register-name'), + registerPasswordInput: document.getElementById('register-password'), + registerConfirmPasswordInput: document.getElementById('register-confirm-password'), + loginError: document.getElementById('login-error'), + registerError: document.getElementById('register-error'), + switchToRegisterBtn: document.getElementById('switch-to-register'), + switchToLoginBtn: document.getElementById('switch-to-login'), + + // Avatar Selection + avatarSelectionGrid: document.getElementById('avatar-selection-grid'), + registerAvatarPreview: document.getElementById('register-avatar-preview'), + + // Main App View + mainAppView: document.getElementById('main-app-view'), + sidebar: document.querySelector('.sidebar'), + chatArea: document.querySelector('.chat-area'), + + // Player Info & Connection + playerIdentity: document.getElementById('player-identity'), + playerCardAvatar: document.getElementById('player-card-avatar'), + playerCardName: document.getElementById('player-card-name'), + logoutButton: document.getElementById('logout-button'), + connectionForm: document.getElementById('connection-form'), + roomIdInput: document.getElementById('room-id-input'), + joinButton: document.getElementById('join-button'), + + // In-Room Info + roomInfo: document.getElementById('room-info'), + roomName: document.getElementById('room-name'), + playerList: document.getElementById('player-list'), + hostControlsContainer: document.getElementById('host-controls-container'), + startGameButton: document.getElementById('start-game-button'), + resumeGameButton: document.getElementById('resume-game-button'), + leaveButton: document.getElementById('leave-button'), + + // Chat + chatLog: document.getElementById('chat-log'), + messageInput: document.getElementById('message-input'), + sendButton: document.getElementById('send-button'), + resolveButton: document.getElementById('resolve-button'), + gmThinkingIndicator: document.getElementById('gm-thinking-indicator'), + themeToggle: document.getElementById('theme-toggle'), + }; + + // Module dependencies, to be set later. + let api = null; + const audio = initAudio(state); + + /** Main render function to switch between primary UI views */ + function _render() { + const mainElement = document.querySelector('main'); + + // Hide all view containers initially + dom.loginView.style.display = 'none'; + dom.registerView.style.display = 'none'; + dom.mainAppView.style.display = 'none'; + + // Set a class on the
element to control the overall layout via CSS + if (state.uiView === 'login' || state.uiView === 'register') { + mainElement.className = 'auth-mode'; + // Show the correct auth form + if (state.uiView === 'login') { + dom.loginView.style.display = 'flex'; + } else { + dom.registerView.style.display = 'flex'; + } + } else { // 'chat' or other authenticated views + 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; + dom.playerCardAvatar.src = `https://api.dicebear.com/9.x/${state.playerInfo.avatar_style}/svg?seed=${encodeURIComponent(state.playerInfo.name)}`; + } else { + dom.playerIdentity.style.display = 'none'; + } + + if (state.isConnected) { + dom.connectionForm.style.display = 'none'; + dom.roomInfo.style.display = 'flex'; + } else { + dom.connectionForm.style.display = 'flex'; + dom.roomInfo.style.display = 'none'; + } + } + } + + function _updateAvatarSelectionUI() { + const name = dom.registerNameInput.value.trim() || 'player'; + dom.registerAvatarPreview.src = `https://api.dicebear.com/9.x/${state.selectedAvatarStyle}/svg?seed=${encodeURIComponent(name)}`; + dom.avatarSelectionGrid.querySelectorAll('.avatar-option').forEach(opt => { + opt.classList.toggle('selected', opt.dataset.style === state.selectedAvatarStyle); + }); + } + + function _populateAvatarGrid() { + const grid = dom.avatarSelectionGrid; + grid.innerHTML = ''; + AVATAR_STYLES.forEach(style => { + const option = document.createElement('div'); + option.className = 'avatar-option'; + option.dataset.style = style; + const img = document.createElement('img'); + img.src = `https://api.dicebear.com/9.x/${style}/svg`; + img.alt = style; + option.appendChild(img); + grid.appendChild(option); + }); + _updateAvatarSelectionUI(); + } + + function _showError(element, message) { + element.textContent = message; + element.style.display = 'block'; + } + + function _hideError(element) { + element.style.display = 'none'; + } + + /** Attach all event listeners for the application */ + function _attachListeners() { + // --- Auth Listeners --- + dom.switchToRegisterBtn.addEventListener('click', () => { state.uiView = 'register'; _render(); }); + dom.switchToLoginBtn.addEventListener('click', () => { state.uiView = 'login'; _render(); }); + + dom.registerForm.addEventListener('submit', async (e) => { + e.preventDefault(); + _hideError(dom.registerError); + 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); + state.uiView = 'login'; + _render(); + } else { + _showError(dom.registerError, result.message); + } + }); + + dom.loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + _hideError(dom.loginError); + 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)); + state.uiView = 'chat'; + _render(); + } else { + _showError(dom.loginError, result.message); + } + }); + + dom.logoutButton.addEventListener('click', () => api.logout()); + + // --- Avatar Selection --- + dom.registerNameInput.addEventListener('input', () => _updateAvatarSelectionUI()); + dom.avatarSelectionGrid.addEventListener('click', (e) => { + const option = e.target.closest('.avatar-option'); + if (option && option.dataset.style) { + state.selectedAvatarStyle = option.dataset.style; + _updateAvatarSelectionUI(); + } + }); + + // --- 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 = ''; + dom.messageInput.focus(); + }); + dom.messageInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + dom.sendButton.click(); + } + }); + 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 --- + 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(); + } + document.body.removeEventListener('click', resumeAudio); + }; + document.body.addEventListener('click', resumeAudio); + } + + // --- Initialize --- + const savedTheme = localStorage.getItem('vdm-theme') || 'dark'; + document.body.classList.toggle('light-theme', savedTheme === 'light'); + _populateAvatarGrid(); + _attachListeners(); + _render(); // Set the initial view based on loaded state + + // --- Public UI Methods --- + /** @type {AppUI} */ + const publicInterface = { + setApi(apiModule) { + api = apiModule; + }, + + 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') { + msgDiv.classList.add(data.author_id === 'gm' ? 'gm' : 'player'); + if (data.is_ooc) msgDiv.classList.add('ooc'); + + const avatarImg = document.createElement('img'); + avatarImg.className = 'msg-avatar'; + + const player = state.room?.players[data.author_id]; + const avatarStyle = player ? player.avatar_style : 'bottts'; + const avatarSeed = data.author_id === 'gm' ? 'GM' : encodeURIComponent(data.author_name); + avatarImg.src = `https://api.dicebear.com/9.x/${data.author_id === 'gm' ? 'bottts' : avatarStyle}/svg?seed=${avatarSeed}`; + + const contentDiv = document.createElement('div'); + contentDiv.className = 'msg-content'; + + const authorSpan = document.createElement('span'); + authorSpan.className = 'author'; + authorSpan.textContent = data.author_name; + + const messageSpan = document.createElement('span'); + let contentHTML = data.content.replace(/`([^`]+)`/g, '$1'); + contentHTML = contentHTML.replace(/\*\*([^\*]+)\*\*/g, '$1'); + messageSpan.innerHTML = contentHTML; + + contentDiv.appendChild(authorSpan); + contentDiv.appendChild(messageSpan); + msgDiv.appendChild(avatarImg); + msgDiv.appendChild(contentDiv); + } + + dom.chatLog.appendChild(msgDiv); + if (!isBatch) { + dom.chatLog.scrollTop = dom.chatLog.scrollHeight; + } + }, + + updateRoomState(room) { + state.room = room; + dom.roomName.textContent = room.room_id; + dom.playerList.innerHTML = ''; // Clear previous list + + Object.values(room.players).forEach(player => { + const playerLi = document.createElement('li'); + playerLi.className = player.is_active ? 'player-active' : 'player-inactive'; + + const avatarImg = document.createElement('img'); + avatarImg.className = 'player-list-avatar'; + avatarImg.src = `https://api.dicebear.com/9.x/${player.avatar_style}/svg?seed=${encodeURIComponent(player.name)}`; + + const nameSpan = document.createElement('span'); + nameSpan.className = 'player-name'; + nameSpan.textContent = player.name; + if (room.host_player_id === player.id) { + nameSpan.textContent += ' 👑'; // Host indicator + } + + const hpSpan = document.createElement('span'); + hpSpan.className = 'player-hp'; + if (player.sheet) { + hpSpan.textContent = `${player.sheet.hp}/${player.sheet.max_hp} HP`; + } + + playerLi.appendChild(avatarImg); + playerLi.appendChild(nameSpan); + playerLi.appendChild(hpSpan); + dom.playerList.appendChild(playerLi); + }); + + // Update UI based on game state + const isHost = (room.host_player_id === state.clientId); + const inLobby = room.game_state === "LOBBY"; + const gmIsProcessing = room.turn_state === "GM_PROCESSING"; + const actionsExist = Object.keys(room.current_turn_actions || {}).length > 0; + + dom.gmThinkingIndicator.style.display = gmIsProcessing ? 'flex' : 'none'; + dom.hostControlsContainer.style.display = isHost ? 'flex' : 'none'; + dom.startGameButton.style.display = inLobby && isHost ? 'block' : 'none'; + dom.resumeGameButton.style.display = !inLobby && isHost && room.messages.length > 0 ? 'block' : 'none'; + dom.resolveButton.disabled = !actionsExist || gmIsProcessing; + dom.messageInput.disabled = gmIsProcessing || inLobby; + dom.sendButton.disabled = gmIsProcessing || inLobby; + }, + + // --- View Changers --- + showRoomView(roomId) { + state.isConnected = true; + state.room = { room_id: roomId, players: {} }; // temporary state + _render(); + }, + showConnectionView() { + state.isConnected = false; + _render(); + }, + showLoginView() { + state.uiView = 'login'; + _render(); + }, + + // --- Streaming Handlers --- + handleStreamStart() { + const msgDiv = document.createElement('div'); + msgDiv.classList.add('msg', 'chat', 'gm', 'streaming'); + const avatarImg = document.createElement('img'); + avatarImg.src = `https://api.dicebear.com/9.x/bottts/svg?seed=GM`; + avatarImg.className = 'msg-avatar'; + const contentDiv = document.createElement('div'); + contentDiv.className = 'msg-content'; + const authorSpan = document.createElement('span'); + authorSpan.className = 'author'; + authorSpan.textContent = 'GM'; + const messageSpan = document.createElement('span'); + + contentDiv.appendChild(authorSpan); + contentDiv.appendChild(messageSpan); + msgDiv.appendChild(avatarImg); + msgDiv.appendChild(contentDiv); + dom.chatLog.appendChild(msgDiv); + + state.activeStream = { + messageElement: msgDiv, + contentElement: messageSpan, + }; + + audio.startStream(); + }, + + handleChatChunk(content) { + if (state.activeStream && state.activeStream.contentElement) { + state.activeStream.contentElement.textContent += content; + dom.chatLog.scrollTop = dom.chatLog.scrollHeight; + } + }, + + handleAudioChunk(chunk) { + audio.queueAndPlay(chunk); + }, + + handleStreamEnd(finalMessage) { + if (state.activeStream && state.activeStream.messageElement) { + state.activeStream.messageElement.classList.remove('streaming'); + } + state.activeStream = null; + audio.endStream(); + }, + + playAudioFile(url) { + audio.playFullAudioFile(url); + } + }; + + return publicInterface; +} \ No newline at end of file diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..01c7ffa --- /dev/null +++ b/web/style.css @@ -0,0 +1,197 @@ +/* =================================================================== */ +/* VDM - FINAL STYLE SHEET */ +/* =================================================================== */ + +/* --- 1. Root Variables & Theming --- */ +:root { + --font-sans: 'Inter', sans-serif; + --error-red: #f04747; + --link-blue: #7289da; + + /* Light Theme */ + --bg-light: #f4f5f7; + --surface-light: #ffffff; + --text-primary-light: #172b4d; + --text-secondary-light: #5e6c84; + --border-light: #dfe1e6; + --accent-light: #0052cc; + --accent-hover-light: #0065ff; + --gm-msg-bg-light: #e6faff; + --ooc-text-light: #42526e; + --system-msg-bg-light: #f0f0f0; + + /* Dark Theme (Default) */ + --bg-dark: #1e2124; + --surface-dark: #282b30; + --text-primary-dark: #e1e1e1; + --text-secondary-dark: #9a9a9a; + --border-dark: #424549; + --accent-dark: #7289da; + --accent-hover-dark: #8a9ff0; + --gm-msg-bg-dark: #2f354d; + --ooc-text-dark: #a0a0a0; + --system-msg-bg-dark: #303338; +} + +/* --- 2. Global Resets & Body Styles --- */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: var(--font-sans); display: flex; flex-direction: column; height: 100vh; overflow: hidden; background-color: var(--bg-dark); color: var(--text-primary-dark); transition: background-color 0.3s, color 0.3s; } +body.light-theme { background-color: var(--bg-light); color: var(--text-primary-light); } + +/* --- 3. Main Layout Containers --- */ +header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1.5rem; background-color: var(--surface-dark); border-bottom: 1px solid var(--border-dark); flex-shrink: 0; } +body.light-theme header { background-color: var(--surface-light); border-bottom-color: var(--border-light); } + +main { + display: flex; + flex-grow: 1; + 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%; + background-color: var(--surface-dark); + padding: 2rem; + border-radius: 8px; + border: 1px solid var(--border-dark); + box-shadow: 0 8px 24px rgba(0,0,0,0.3); +} + +body.light-theme main.auth-mode > .auth-form-container { + background-color: var(--surface-light); + border-color: var(--border-light); + 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%; +} + +.sidebar { width: 280px; flex-shrink: 0; background-color: var(--surface-dark); padding: 1.5rem; display: flex; flex-direction: column; gap: 2rem; overflow-y: auto; border-right: 1px solid var(--border-dark); } +body.light-theme .sidebar { background-color: var(--surface-light); border-right-color: var(--border-light); } +.chat-area { display: flex; flex-direction: column; flex-grow: 1; } + +/* --- 4. Sidebar Components (Auth, Connection, Room Info) --- */ +.sidebar-section { display: flex; flex-direction: column; gap: 1rem; } +.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); } +body.light-theme #register-avatar-preview { border-color: var(--border-light); } +.avatar-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 0.5rem; } +.avatar-option { cursor: pointer; border-radius: 8px; padding: 0.25rem; border: 2px solid transparent; transition: border-color 0.2s; } +.avatar-option:hover { border-color: var(--border-dark); } +.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; } +#player-list li { display: flex; align-items: center; gap: 0.75rem; color: var(--text-secondary-dark); font-size: 0.9rem;} +body.light-theme #player-list li { color: var(--text-secondary-light); } +#player-list .player-inactive { opacity: 0.5; font-style: italic; } +.player-list-avatar { width: 28px; height: 28px; border-radius: 50%; background-color: var(--border-dark); } +body.light-theme .player-list-avatar { background-color: var(--border-light); } +.player-name { flex-grow: 1; } +.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 --- */ +button { padding: 0.65rem 1rem; font-family: var(--font-sans); font-size: 0.9rem; font-weight: 500; border-radius: 5px; border: none; cursor: pointer; transition: background-color 0.2s, color 0.2s, opacity 0.2s; display: flex; align-items: center; justify-content: center; gap: 0.5rem; width: 100%; } +button:disabled { cursor: not-allowed; opacity: 0.5; } + +#theme-toggle { background: none; border: none; font-size: 1.5rem; padding: 0.25rem; margin-left: auto; width: auto; } + +.sidebar button, .auth-form-container button { background-color: var(--accent-dark); color: white; } +.sidebar button:not(:disabled):hover, .auth-form-container button:not(:disabled):hover { background-color: var(--accent-hover-dark); } +body.light-theme .sidebar button, body.light-theme .auth-form-container button { background-color: var(--accent-light); } +body.light-theme .sidebar button:not(:disabled):hover, body.light-theme .auth-form-container button:not(:disabled):hover { background-color: var(--accent-hover-light); } + +#leave-button, #logout-button { background-color: var(--bg-dark); color: var(--text-primary-dark); border: 1px solid var(--border-dark); } +#leave-button:hover, #logout-button:hover { background-color: var(--surface-dark); } +body.light-theme #leave-button, body.light-theme #logout-button { background-color: var(--bg-light); color: var(--text-primary-light); border-color: var(--border-light); } +body.light-theme #leave-button:hover, body.light-theme #logout-button:hover { background-color: var(--surface-light); } + +.chat-input-area button { background-color: var(--accent-dark); color: white; width: auto; } +.chat-input-area button:not(:disabled):hover { background-color: var(--accent-hover-dark); } +body.light-theme .chat-input-area button { background-color: var(--accent-light); } +body.light-theme .chat-input-area button:not(:disabled):hover { background-color: var(--accent-hover-light); } + +#mic-button.listening { color: var(--error-red); } + +input[type="text"], input[type="password"], textarea { width: 100%; padding: 0.65rem 1rem; border-radius: 5px; border: 1px solid var(--border-dark); background-color: var(--bg-dark); color: var(--text-primary-dark); font-family: var(--font-sans); font-size: 0.9rem; margin-bottom: 0.5rem; } +body.light-theme input[type="text"], body.light-theme input[type="password"], body.light-theme textarea { border-color: var(--border-light); background-color: var(--bg-light); color: var(--text-primary-light); } +input[type="text"]:focus, input[type="password"]:focus, textarea:focus { outline: none; border-color: var(--accent-dark); box-shadow: 0 0 0 2px rgba(114, 137, 218, 0.3); } +body.light-theme input[type="text"]:focus, body.light-theme input[type="password"]:focus, body.light-theme textarea:focus { border-color: var(--accent-light); box-shadow: 0 0 0 2px rgba(0, 82, 204, 0.3); } + +/* --- 6. Chat Area Components --- */ +#chat-log { flex-grow: 1; padding: 1.5rem; overflow-y: auto; display: flex; flex-direction: column; gap: 1.25rem; } +.chat-input-area { position: relative; flex-shrink: 0; padding: 1rem 1.5rem; display: flex; gap: 0.75rem; background-color: var(--surface-dark); border-top: 1px solid var(--border-dark); align-items: flex-end;} +body.light-theme .chat-input-area { background-color: var(--surface-light); border-top-color: var(--border-light); } +#message-input { flex-grow: 1; resize: none; overflow-y: hidden; min-height: 42px; margin-bottom: 0; } + +/* --- 7. Chat Messages --- */ +.msg { max-width: 90%; display: flex; gap: 1rem; align-items: flex-start; } +.msg.gm { align-self: flex-start; } +.msg.player { align-self: flex-start; } + +.msg-avatar { width: 40px; height: 40px; border-radius: 50%; margin-top: 3px; flex-shrink: 0; background-color: var(--border-dark); } +body.light-theme .msg-avatar { background-color: var(--border-light); } +.msg-content { display: flex; flex-direction: column; background-color: var(--surface-dark); padding: 0.75rem 1rem; border-radius: 8px; } +body.light-theme .msg-content { background-color: var(--surface-light); } +.msg.gm .msg-content { background-color: var(--gm-msg-bg-dark); } +body.light-theme .msg.gm .msg-content { background-color: var(--gm-msg-bg-light); } + +.msg .author { font-weight: 700; margin-bottom: 0.25rem; color: var(--text-primary-dark); } +body.light-theme .msg .author { color: var(--text-primary-light); } +.msg.gm .author { color: var(--accent-dark); } +body.light-theme .msg.gm .author { color: var(--accent-light); } + +.msg.ooc { font-style: italic; opacity: 0.8; } +.msg.ooc .msg-content { background-color: transparent; } + +.msg.system { align-self: center; background-color: var(--system-msg-bg-dark); color: var(--text-secondary-dark); padding: 0.5rem 1rem; border-radius: 1rem; font-size: 0.85rem; text-align: center; max-width: 70%; } +body.light-theme .msg.system { background-color: var(--system-msg-bg-light); color: var(--text-secondary-light); } + +.msg code { background-color: var(--bg-dark); padding: 0.1rem 0.3rem; border-radius: 4px; font-family: monospace; } +body.light-theme .msg code { background-color: var(--bg-light); } +.msg strong { font-weight: 700; } + +/* --- 8. Special UI States & Elements --- */ +#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); } } \ No newline at end of file