Initial Commit

This commit is contained in:
Nighthawk
2025-09-04 23:21:18 -04:00
parent a1dfbb40e2
commit da0eff83fb
28 changed files with 3920 additions and 0 deletions
+23
View File
@@ -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"
+10
View File
@@ -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
+89
View File
@@ -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 <v> tag with their name. All text outside of a <v> tag is considered narration.
The character name should be simple and consistent.
Narration and Dialogue Example:
The old man looks up from his book. <v name="Gandalf">You are late.</v> He says,
slamming the book shut. <v name="Gandalf">Just as I foretold.</v>
# 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 <thinking> block and a <RESPONSE> block.
Example:
<thinking>The player wants to inspect the chest. I'll make the lock unusual.</thinking>
<RESPONSE>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?</RESPONSE>
+150
View File
@@ -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) <!-- Replace with a real screenshot URL -->
---
## ✨ 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 `<YOUR_IP_HERE>` 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 <YOUR_IP_HERE>
```
---
### 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.
+30
View File
@@ -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
+90
View File
@@ -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 (`<thinking>`/`<RESPONSE>`).
- **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 (`<v>` tags), and feature flags are **DONE**.
- **To-Do:**
- [ ] Rebuild the `AudioManager` into an "Audio Director" that parses `<v>` 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., `<ITEM name="Health Potion" />`), 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., `<ACTION type="skill_check" skill="dexterity" difficulty="15" />`).
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 `<IMAGE prompt="A dark, mossy cave entrance" />`. 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.
+242
View File
@@ -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'(<v name=".*?">.*?</v>)', text, flags=re.DOTALL)
for segment in segments:
if not segment.strip(): continue
dialogue_match = re.match(r'<v name="(.*?)">(.*?)</v>', 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'(<v name=".*?">.*?</v>)', text, flags=re.DOTALL)
for segment in segments:
sanitized_segment = self._sanitize_for_tts(segment)
if not sanitized_segment: continue
dialogue_match = re.match(r'<v name="(.*?)">(.*?)</v>', segment, flags=re.DOTALL)
if dialogue_match:
char_name, dialogue_text = dialogue_match.groups()
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"]}
+79
View File
@@ -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()
+85
View File
@@ -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.")
+60
View File
@@ -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}**"
+273
View File
@@ -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}")
+51
View File
@@ -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")
+525
View File
@@ -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()),
)
+157
View File
@@ -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 []
+69
View File
@@ -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]
+43
View File
@@ -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)
+134
View File
@@ -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)
+262
View File
@@ -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'<RESPONSE>(.*)</RESPONSE>', raw_text, re.DOTALL)
if match:
return match.group(1).strip()
logger.warning("llm_uses_tags is true, but could not find <RESPONSE>. Using raw text.")
return raw_text.replace("<thinking>", "").replace("</thinking>", "").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)
+150
View File
@@ -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.")
+87
View File
@@ -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 <thinking> and
# <RESPONSE> 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"
+36
View File
@@ -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"
+120
View File
@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VDM - Virtual Dungeon Master</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<div id="app-title">VDM</div>
<button id="theme-toggle" title="Toggle Light/Dark Mode">🌙</button>
</header>
<main>
<div id="login-view" class="sidebar-section auth-form-container">
<h3>Welcome Back!</h3>
<form id="login-form">
<div>
<label for="login-name">Username</label>
<input type="text" id="login-name" placeholder="Your Username" required>
</div>
<div>
<label for="login-password">Password</label>
<input type="password" id="login-password" placeholder="Your Password" required>
</div>
<button type="submit">Log In</button>
</form>
<div id="login-error" class="error-message" style="display: none;"></div>
<p class="small-text">Don't have an account? <button type="button" id="switch-to-register" class="link-button">Create one</button></p>
</div>
<div id="register-view" class="sidebar-section auth-form-container" style="display: none;">
<h3>Create Your Player</h3>
<form id="register-form">
<div>
<label for="register-name">Username</label>
<input type="text" id="register-name" placeholder="e.g., Elara" required>
</div>
<div>
<label for="register-password">Password</label>
<input type="password" id="register-password" placeholder="Create a password" required>
</div>
<div>
<label for="register-confirm-password">Confirm Password</label>
<input type="password" id="register-confirm-password" placeholder="Confirm password" required>
</div>
<div class="avatar-selection">
<label>Choose an Avatar Style</label>
<div class="avatar-preview-container">
<img id="register-avatar-preview" src="" alt="Avatar Preview">
</div>
<div id="avatar-selection-grid" class="avatar-grid"></div>
</div>
<button type="submit">Register</button>
</form>
<div id="register-error" class="error-message" style="display: none;"></div>
<p class="small-text">Already have an account? <button type="button" id="switch-to-login" class="link-button">Log in</button></p>
</div>
<div id="main-app-view" style="display: flex; flex-grow: 1; overflow: hidden;">
<aside class="sidebar">
<div id="player-identity" class="sidebar-section" style="display: none;">
<h3>My Identity</h3>
<div class="player-card">
<img id="player-card-avatar" src="" alt="Player Avatar">
<span id="player-card-name"></span>
</div>
<button type="button" id="logout-button">Change Identity</button>
</div>
<div id="connection-form" class="sidebar-section" style="display: none;">
<h3>Join a Game</h3>
<div>
<label for="room-id-input">Room ID</label>
<input type="text" id="room-id-input" value="dungeon-one" placeholder="e.g., dragon-lair" required>
</div>
<button type="button" id="join-button">Join Game</button>
</div>
<div id="room-info" class="sidebar-section" style="display: none;">
<h3>Room: <span id="room-name"></span></h3>
<div id="player-list-container">
<h4>Players</h4>
<ul id="player-list"></ul>
</div>
<div id="host-controls-container" style="display: none;">
<h4>Host Controls</h4>
<button type="button" id="start-game-button">Start Game</button>
<button type="button" id="resume-game-button">Resume Game</button>
</div>
<button type="button" id="leave-button" class="mt-auto">Leave Room</button>
</div>
</aside>
<section class="chat-area">
<div id="chat-log">
<div class="msg system">Welcome to VDM! Please log in or register to start.</div>
</div>
<div id="gm-thinking-indicator" style="display: none;">
<div class="spinner"></div>
<span>The GM ponders...</span>
</div>
<div class="chat-input-area">
<div id="command-preview"></div>
<textarea id="message-input" placeholder="Enter your action or message..." rows="1" disabled></textarea>
<button id="mic-button" type="button" title="Hold to Talk" style="display: none;" disabled>🎤</button>
<button id="send-button" type="button" title="Send your action or message" disabled>Send</button>
<button id="resolve-button" type="button" title="Submit all actions and continue the story" disabled>Continue ▶</button>
</div>
</section>
</div>
</main>
<script type="module" src="/static/js/app.js"></script>
</body>
</html>
+195
View File
@@ -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;
}
+53
View File
@@ -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();
});
+191
View File
@@ -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;
}
+81
View File
@@ -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.
}
}
+438
View File
@@ -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<ApiModule>) => 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 <main> element to control the overall layout via CSS
if (state.uiView === 'login' || state.uiView === 'register') {
mainElement.className = 'auth-mode';
// Show the correct auth form
if (state.uiView === 'login') {
dom.loginView.style.display = 'flex';
} else {
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, '<code>$1</code>');
contentHTML = contentHTML.replace(/\*\*([^\*]+)\*\*/g, '<strong>$1</strong>');
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;
}
+197
View File
@@ -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); } }