mirror of
https://github.com/Nighthawk42/VDM.git
synced 2026-08-30 04:30:21 +00:00
Initial Commit
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
 <!-- 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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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"]}
|
||||
@@ -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()
|
||||
@@ -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.")
|
||||
@@ -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}**"
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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()),
|
||||
)
|
||||
@@ -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 []
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.")
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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); } }
|
||||
Reference in New Issue
Block a user