From 21ecc7dac6fd3e738fc6cbebc0811aabbd70c986 Mon Sep 17 00:00:00 2001 From: Nighthawk Date: Fri, 5 Sep 2025 06:18:36 -0400 Subject: [PATCH] Added markdown support, updated readme. --- prompts.yml | 80 +++++++------ readme.md | 146 ++++++++++-------------- server/main.py | 219 +++++++++--------------------------- server/models.py | 12 +- web/index.html | 5 +- web/js/markdown-renderer.js | 25 ++++ web/js/ui.js | 71 +++--------- 7 files changed, 195 insertions(+), 363 deletions(-) create mode 100644 web/js/markdown-renderer.js diff --git a/prompts.yml b/prompts.yml index e1d4c0e..0c95b3b 100644 --- a/prompts.yml +++ b/prompts.yml @@ -2,9 +2,11 @@ # 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. +# You can customize the GM's personality and instructions here by editing +# the text blocks below. # --- Prompt for the initial game setup phase --- +# This prompt is used only for the very first turn of a new, empty game room. 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. @@ -13,77 +15,81 @@ setup: > and a brief description of the setting. Keep your request to a single, welcoming paragraph. # --- Prompt for resuming a game in progress --- +# This prompt is used when the "Resume Game" button is clicked in a room with +# existing history. It helps re-orient the players. 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?". + of the immediate circumstances and any pressing dangers or questions, and then end your + narration with an open-ended description that invites the players to act. # --- Main prompt for ongoing gameplay --- +# This section defines the core behavior of the GM for every turn after the setup. gameplay: - # The core instructions and personality for the GM. + # The base instructions and personality for the GM. This is the most important part. 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. + ## PERSONALITY TRAITS + - **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 what a player character does, thinks, or feels. Instead, present situations and narrate the consequences of their actions. + - **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. + ## CRITICAL RULES + - **Formatting**: You MUST use Markdown for emphasis. Use single asterisks for italics (e.g., *The Wanderer*) and double asterisks for bold (e.g., **Be careful!**). This makes the story more readable. + - **Ending Turns**: To prompt players for their actions, avoid always asking "What do you do?". Instead, end your narration with an open-ended description of the scene that invites a response. Describe an NPC's expectant gaze, a newly revealed path, or the tense silence after a dramatic event. + + # This instruction block is added if `prompting_strategy` is set to "json" in settings.yml. json_input_instruction: > - CRITICAL INPUT FORMAT: For each turn, you will receive a JSON array detailing each player's + **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 + **CRITICAL AGENCY RULE**: You must NEVER generate actions or 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: + **INPUT EXAMPLE (MULTIPLAYER TURN)**: ```json [ { - "player_name": "Player 1", - "action": "Approaches the large Gamorrean at the stage.", - "dialogue": "What's your name, big guy?" + "player_name": "Dash", + "action": "Approaches the bar and leans against it, looking tired.", + "dialogue": "I'll have whatever's strongest." }, { - "player_name": "Player 2", - "action": "Finishes his song and gives a hearty laugh.", - "dialogue": "Chris P. Bacon, at your service!" + "player_name": "Lyra", + "action": "Keeps an eye on the door, her hand resting near her blaster.", + "dialogue": "" } ] ``` - # --- FALLBACK: Instruction for legacy text-based input --- - # This is for models that may not handle structured JSON well. + # This instruction block is added if `prompting_strategy` is set to "legacy_text". 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!" + [Dash]: Approaches the bar. "I'll have whatever's strongest." + [Lyra]: Keeps an eye on the door, her hand resting near her blaster. - # --- Instruction for Voice Tagging --- - # This tells the AI how to format dialogue for our Dynamic Voice Casting system. + # This instruction is added if `enable_dynamic_casting` is true in settings.yml. voice_tagging_instruction: > - IMPORTANT VOICE RULE: When a character or creature speaks, you MUST enclose their dialogue - in a tag with their name. All text outside of a tag is considered narration. - The character name should be simple and consistent. + **IMPORTANT VOICE RULE**: When a non-player character (NPC) or creature speaks, you MUST enclose their dialogue + in a `` tag with their name. All text outside of a `` tag is considered narration. + Non-speaking sounds like grunts or sighs should be part of the narration, outside the tags. - Narration and Dialogue Example: - The old man looks up from his book. You are late. He says, + **EXAMPLE**: + The old man looks up from his book with a sigh. You are late. He says, slamming the book shut. Just as I foretold. - # An additional instruction for models that support reasoning tags. + # This instruction is added if `llm_uses_tags` is true in settings.yml. tagging_instruction: > - CRITICAL OUTPUT FORMAT: You MUST format your entire output in two parts using XML-style tags: - a block and a block. + **CRITICAL OUTPUT FORMAT**: You MUST format your entire output in two parts using XML-style tags: + a `` block for your reasoning and a `` block for your narration to the players. - Example: - The player wants to inspect the chest. I'll make the lock unusual. + **EXAMPLE**: + The player wants to inspect the chest. I will make the lock unusual to create a small puzzle. I will describe the runes to invite further inspection. You approach the heavy oaken chest. Instead of a keyhole, you see a small, - circular indentation with three strange runes carved around it. What do you do? \ No newline at end of file + circular indentation with three strange runes carved around it. The air grows colder near the chest. \ No newline at end of file diff --git a/readme.md b/readme.md index b616244..75ec474 100644 --- a/readme.md +++ b/readme.md @@ -1,25 +1,41 @@ # 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. +![VDM Logo](https://i.imgur.com/10m2zls.png) -The project is built with a "Keep It Simple, Stupid" (KISS) philosophy, leveraging modern, high-performance tools to create a robust foundation that is easy to understand, maintain, and extend. +VDM is a multiplayer, AI-driven storytelling game designed for immersive, collaborative role-playing. It combines a powerful, locally-run backend with a clean, modular web interface, allowing you and your friends to create and experience epic adventures narrated by a sophisticated AI Game Master. -![VDM Thematic UI Screenshot](https://i.imgur.com/your-screenshot-url.png) +The project is built with a focus on stability and cutting-edge features, leveraging modern, high-performance tools to create a robust foundation that is easy to understand, maintain, and extend. --- ## ✨ 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. +### Core Gameplay +* **AI-Powered Game Master:** A sophisticated LLM 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. +* **Turn-Based System:** A structured turn system allows players to declare their actions, which are then submitted to the GM as a single turn for resolution. +* **Session Persistence:** Save your game with the `/save` command. The server automatically reloads your session when you rejoin the room. + +### Advanced AI Memory +* **State-of-the-Art RAG Pipeline:** The AI has a true long-term memory, powered by Google's **EmbeddingGemma** model for top-tier embeddings and the **Chonkie** library for intelligent semantic chunking. +* **Local Vector Store:** All memories are stored locally in a `ChromaDB` vector database. +* **Manual Memory Control:** Players can use the `/remember` command to ensure critical facts are permanently stored in the AI's memory. + +### Immersive Experience +* **High-Quality Voice Narration:** The GM's responses are brought to life with server-side Text-to-Speech using the `kokoro` library. +* **Speech-to-Text Input:** A "Hold to Talk" microphone button allows players to speak their actions instead of typing. +* **Markdown Rendering:** GM and player messages are rendered with Markdown for better formatting and readability (e.g., *italics* and **bold**). + +### Modern UI/UX +* **Clean & Responsive Interface:** A modern single-page application that works on any device. +* **Light/Dark Modes:** A persistent theme toggle for user comfort. +* **Modular Frontend:** The JavaScript is broken down into small, maintainable modules for features like auth, commands, and avatars. +* **Visual Turn Indicator:** See at a glance which players have submitted their action for the current turn. + +### Robust Backend * **Pluggable AI Backends:** Easily switch between different LLM providers, including local options like **LM Studio** and **Ollama**, or cloud services like **OpenRouter**. +* **Secure User Accounts:** Player accounts are stored with hashed passwords in a dedicated SQLite database. +* **Persistent Sessions:** User logins survive server restarts, allowing for a seamless reconnection experience. --- @@ -30,114 +46,66 @@ 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. - ---- +* **`uv`**: A fast Python package installer. If you don't have it, run: + ```bash + pip install uv + ``` +* **(Optional) NVIDIA GPU**: For the best performance with local LLMs and TTS. ### 1. Project Setup First, clone or download the project repository. ---- +### 2. Create and Activate the Virtual Environment -### 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: +Open your terminal in the project's root directory and run: ```bash -# This creates a .venv folder using Python 3.11 -uv venv --python 3.11 --seed -``` +# Create the virtual environment +uv venv ---- - -### 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 +# Activate the environment +# On Windows: .venv\Scripts\activate +# On macOS / Linux: +# source .venv/bin/activate ``` -**On macOS / Linux:** -```bash -source .venv/bin/activate -``` +### 3. Install Dependencies -Your terminal prompt should now be prefixed with `(.venv)`. - ---- - -### 4. Install Dependencies - -Install all required Python packages using the requirements.txt file. +Install all required Python packages. **Note:** This step will download large AI models for embeddings and TTS, which may take some time. ```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. +### 4. Configure the VDM ---- + * **Environment Variables**: Copy `.env.example` to `.env` and fill in any necessary API keys or change the default URLs for your local LLM providers. + * **Main Configuration**: Open `settings.yml` to configure the core behavior, especially the `llm` and `memory` sections to select your AI backend and chunking strategy. + * **Prompts & Voices (Optional)**: Edit `prompts.yml` to change the GM's personality or `voices.yml` for dynamic voice casting. -### 5. Configure the VDM +### 5. Launch the Server! -The VDM is configured using simple YAML files. +Simply run the launch script. It will activate the environment and start the Uvicorn server. -* **Main Configuration:** Copy `config.yml.example` to `config.yml`. Open the new file and configure it, paying special attention to the `llm` section to select your AI backend (`lmstudio`, `ollama`, `openrouter`) and provide your API key if needed. -* **Prompts (Optional):** Edit `prompts.yml` to change the GM's personality and instructions. -* **Voices (Optional):** If you enable `enable_dynamic_casting` in your config, edit `voices.yml` to assign custom voices to characters. - ---- - -### 6. Generate SSL Certificate (for HTTPS) - -The microphone feature requires a secure (HTTPS) connection. - -1. **(One-Time Setup) Install a local Certificate Authority:** - ```bash - mkcert -install - ``` - -2. **Generate Certificate:** - From your project's root directory, create a `ssl` folder. Then run the mkcert command, replacing `` with your actual local network or ZeroTier IP address. - - ```bash - mkdir ssl - mkcert -key-file ./ssl/key.pem -cert-file ./ssl/cert.pem localhost 127.0.0.1 ::1 - ``` - ---- - -### 7. Launch the Server! - -Simply run the launch script. It will handle activating the environment and starting the server with all the correct settings. - -**On Windows:** ```cmd -launch.bat +uvicorn server.main:app --reload --reload-dir ./server --reload-dir ./web --host 127.0.0.1 --port 8000 ``` -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! +The server will be running at `http://127.0.0.1:8000`. 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. +1. **Connect:** Open your browser to the server's address. +2. **Login/Register:** Create a persistent player account. +3. **Join a Room:** Enter a Room ID to join or create a game. The first player becomes the room's permanent owner. +4. **Start the Game:** The room owner clicks "Start Game" to begin the collaborative setup. +5. **Declare Actions:** During gameplay, type or speak what your character does or says. Your action is added to the turn queue. +6. **Submit the Turn:** When all players are ready, 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. --- diff --git a/server/main.py b/server/main.py index 73e5654..adac5b0 100644 --- a/server/main.py +++ b/server/main.py @@ -46,27 +46,21 @@ app.mount("/audio", StaticFiles(directory=Path(settings.paths.audio_out_dir)), n 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])}" - ) + 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()))}" - ) + 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 + if room_id not in self.connections: return payload = message.model_dump_json() tasks = [ connection.send_text(payload) @@ -75,7 +69,6 @@ class ConnectionManager: ] await asyncio.gather(*tasks) - # --- Instantiate Managers --- db_manager = DatabaseManager( sessions_db_path=Path(settings.memory.sessions_db_file), @@ -88,8 +81,6 @@ story_manager = StoryManager() audio_manager = AudioManager() game_manager = DiceRoller() connection_manager = ConnectionManager() - - # =================================================================== # Core Game Loop Logic # =================================================================== @@ -261,12 +252,8 @@ async def _advance_turn_non_streaming( 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 + # The check for host actions will now happen inside the websocket_endpoint + if (not room_state or room_state.game_state != "PLAYING"): return await connection_manager.broadcast( room_id, @@ -297,40 +284,30 @@ async def _resume_game_turn(room_id: str, player: Player): 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) + 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.") + 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.get("/favicon.ico", include_in_schema=False) async def favicon(): return FileResponse(BASE_DIR / "web/favicon.ico") @@ -349,23 +326,20 @@ async def websocket_endpoint( room, player = add_player_result if room.messages: - await websocket.send_text( - WSOutgoingMessage( - kind="chat_history", payload={"messages": [m.model_dump() for m in room.messages]} - ).model_dump_json() - ) + await websocket.send_text(WSOutgoingMessage(kind="chat_history", payload={"messages": [m.model_dump() for m in room.messages]}).model_dump_json()) - if not room.host_player_id: - room.host_player_id = player_id - logger.info(f"Player '{player.name}' is now the host of room '{room_id}'.") + # --- Permanent Owner and Host Logic --- + # If the room has no permanent owner, this player becomes the owner. + if not room.owner_username: + room.owner_username = player.name + logger.info(f"Player '{player.name}' is the permanent owner of room '{room_id}'.") + + # The active host is always the owner, if they are present. + if room.owner_username == player.name: + room.host_player_id = player.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()) - ) + 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: @@ -373,136 +347,59 @@ async def websocket_endpoint( try: msg = WSIncomingMessage.model_validate_json(data) room_state = room_manager.get_room(room_id) - if not room_state: - continue + if not room_state: continue + + # Check if the current player is the room owner for host actions. + is_owner = (room_state.owner_username == player.name) if msg.kind == "start_game": - if ( - player.id == room_state.host_player_id - and room_state.game_state == "LOBBY" - ): + if is_owner 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 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() - ), - ) - + 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) + if is_owner: + 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 not text: continue + if text.startswith("/"): + 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, - }, - ), - ) + 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}." - }, - ), - ) + 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]}...'" - }, - ), - ) + 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, - }, - ), - ) + 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() - ) + 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 + 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() - ), - ) + 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) @@ -511,28 +408,14 @@ async def websocket_endpoint( 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 the disconnecting player was the host, clear the temporary host ID. 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." - }, - ), - ) + room_state.host_player_id = None + await connection_manager.broadcast( room_id, - WSOutgoingMessage( - kind="system", - payload={"message": f"{disconnected_player.name} has left the game."}, - ), + WSOutgoingMessage(kind="system", payload={"message": f"{disconnected_player.name} has left the game."}), ) await connection_manager.broadcast( room_id, diff --git a/server/models.py b/server/models.py index 26aeef7..90d15c6 100644 --- a/server/models.py +++ b/server/models.py @@ -6,22 +6,13 @@ from typing import Dict, List, Literal, Any, Optional # Core Game & Application Models # =================================================================== -class PlayerSheet(BaseModel): - """Represents a player's character sheet with stats and inventory.""" - hp: int = 10 - max_hp: int = 10 - # Placeholders for future expansion - # attributes: Dict[str, int] = Field(default_factory=dict) - # inventory: List[str] = Field(default_factory=list) - - class Player(BaseModel): """Represents a player within a game room.""" id: str name: str avatar_style: str = "adventurer" is_active: bool = True - sheet: PlayerSheet = Field(default_factory=PlayerSheet) + # REMOVED: The 'sheet' attribute has been removed from the Player model. class ChatMessage(BaseModel): @@ -41,6 +32,7 @@ class Room(BaseModel): current_turn_actions: Dict[str, str] = Field(default_factory=dict) game_state: Literal["LOBBY", "PLAYING"] = "LOBBY" host_player_id: Optional[str] = None + owner_username: Optional[str] = None class RegisterRequest(BaseModel): """Model for the /api/register endpoint payload.""" diff --git a/web/index.html b/web/index.html index 79cbe10..89c2102 100644 --- a/web/index.html +++ b/web/index.html @@ -10,11 +10,12 @@ +
-
VDM - Virtual Dungeon Master - Version 0.1 [Alpha]
+
VDM
@@ -108,7 +109,7 @@
- +
diff --git a/web/js/markdown-renderer.js b/web/js/markdown-renderer.js new file mode 100644 index 0000000..fa48b70 --- /dev/null +++ b/web/js/markdown-renderer.js @@ -0,0 +1,25 @@ +// web/js/markdown-renderer.js + +// Import the libraries directly from the CDN. +// This ensures they are loaded before this code runs. +import { marked } from "https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js"; +import DOMPurify from "https://cdn.jsdelivr.net/npm/dompurify/dist/purify.es.js"; + +/** + * Safely converts a string of Markdown text into sanitized HTML. + * @param {string} markdownText The raw text to convert. + * @returns {string} The sanitized HTML string, ready to be inserted into the DOM. + */ +export function renderMarkdown(markdownText) { + if (typeof markdownText !== 'string' || !markdownText) { + return ""; + } + + // 1. Convert the raw text from the server into HTML using marked.js + const dirtyHtml = marked.parse(markdownText); + + // 2. Sanitize that HTML using DOMPurify to prevent XSS attacks. + const cleanHtml = DOMPurify.sanitize(dirtyHtml); + + return cleanHtml; +} \ No newline at end of file diff --git a/web/js/ui.js b/web/js/ui.js index 56f6210..5c81cf2 100644 --- a/web/js/ui.js +++ b/web/js/ui.js @@ -4,38 +4,22 @@ import { dom } from './dom-elements.js'; import { initSpeechRecognition } from './speech-recognition.js'; import { initCommandPreview } from './commands.js'; import { initAvatarSelection } from './avatars.js'; +import { renderMarkdown } from './markdown-renderer.js'; /** * @typedef {import('./state.js').AppState} AppState * @typedef {import('./api.js').initApi} ApiModule */ -/** - * @typedef {object} AppUI - The public interface of the UI module. - * @property {(api: ReturnType) => void} setApi - * @property {(type: string, data: any, isBatch: boolean) => void} logMessage - * @property {(messages: any[]) => void} loadChatHistory - * @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 - */ - /** * Initializes and returns the UI module, which orchestrates all sub-modules. * @param {AppState} state - The central state object. - * @returns {AppUI} + * @returns {object} The public interface for the UI module. */ export function initUI(state) { let api = null; const audio = initAudio(state); - // Initialize all imported UI sub-modules const speech = initSpeechRecognition({ onFinalResult: (text) => { dom.messageInput.value = text; }, onStatusChange: (isListening) => { dom.micButton.classList.toggle('listening', isListening); } @@ -43,9 +27,6 @@ export function initUI(state) { initCommandPreview(dom); initAvatarSelection(dom, state); - /** - * Main render function to switch between the primary UI views (auth vs. app). - */ function _render() { const mainElement = document.querySelector('main'); dom.loginView.style.display = 'none'; @@ -59,10 +40,9 @@ export function initUI(state) { } else { dom.registerView.style.display = 'flex'; } - } else { // 'chat' or other authenticated views + } else { mainElement.className = 'app-mode'; dom.mainAppView.style.display = 'flex'; - if (state.playerInfo) { dom.playerIdentity.style.display = 'flex'; dom.playerCardName.textContent = state.playerInfo.name; @@ -70,7 +50,6 @@ export function initUI(state) { } else { dom.playerIdentity.style.display = 'none'; } - if (state.isConnected) { dom.connectionForm.style.display = 'none'; dom.roomInfo.style.display = 'flex'; @@ -81,23 +60,18 @@ export function initUI(state) { } } - /** Displays an error message in a designated element. */ function _showError(element, message) { element.textContent = message; element.style.display = 'block'; } - /** Hides an error message element. */ function _hideError(element) { element.style.display = 'none'; } - /** Attaches all persistent event listeners for the application. */ function _attachListeners() { - // --- Auth & Connection 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); @@ -109,7 +83,6 @@ export function initUI(state) { 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); @@ -123,15 +96,12 @@ export function initUI(state) { _render(); } else { _showError(dom.loginError, result.message); } }); - dom.logoutButton.addEventListener('click', () => api.logout()); dom.joinButton.addEventListener('click', () => { const roomId = dom.roomIdInput.value.trim(); if (roomId) api.connectToRoom(roomId); }); dom.leaveButton.addEventListener('click', () => api.disconnect()); - - // --- Chat & Speech Listeners --- dom.sendButton.addEventListener('click', () => { const message = dom.messageInput.value.trim(); if (message) api.sendMessage('say', { message }); @@ -149,13 +119,9 @@ export function initUI(state) { } else { dom.micButton.style.display = 'none'; } - - // --- Host & Game Listeners --- dom.resolveButton.addEventListener('click', () => api.sendMessage('submit_turn')); 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'); @@ -167,13 +133,11 @@ export function initUI(state) { document.body.addEventListener('click', resumeAudio); } - // --- Initialize --- const savedTheme = localStorage.getItem('vdm-theme') || 'dark'; document.body.classList.toggle('light-theme', savedTheme === 'light'); _attachListeners(); _render(); - // --- Public Interface --- const publicInterface = { setApi(apiModule) { api = apiModule; }, logMessage(type, data, isBatch = false) { @@ -188,7 +152,6 @@ export function initUI(state) { 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); @@ -196,15 +159,12 @@ export function initUI(state) { const contentDiv = document.createElement('div'); contentDiv.className = 'msg-content'; - const authorSpan = document.createElement('span'); authorSpan.className = 'author'; authorSpan.textContent = data.author_name; - const messageSpan = document.createElement('span'); - let contentHTML = data.content.replace(/`([^`]+)`/g, '$1'); - contentHTML = contentHTML.replace(/\*\*([^\*]+)\*\*/g, '$1'); - messageSpan.innerHTML = contentHTML; + + messageSpan.innerHTML = renderMarkdown(data.content); contentDiv.appendChild(authorSpan); contentDiv.appendChild(messageSpan); @@ -230,40 +190,34 @@ export function initUI(state) { 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 += ' 👑'; } - + if (room.owner_username === player.name) { nameSpan.textContent += ' 👑'; } const turnIndicator = document.createElement('span'); turnIndicator.className = 'turn-indicator'; if (room.current_turn_actions && room.current_turn_actions[player.id]) { playerLi.classList.add('action-submitted'); turnIndicator.textContent = '✅'; } - - // REMOVED: The hpSpan logic is gone. - playerLi.appendChild(avatarImg); playerLi.appendChild(nameSpan); playerLi.appendChild(turnIndicator); dom.playerList.appendChild(playerLi); }); - const isHost = (state.playerInfo && room.host_player_id === state.clientId); + const isOwner = (state.playerInfo && room.owner_username === state.playerInfo.name); 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.hostControlsContainer.style.display = isOwner ? 'flex' : 'none'; + dom.startGameButton.style.display = inLobby && isOwner ? 'block' : 'none'; + dom.resumeGameButton.style.display = !inLobby && isOwner && room.messages.length > 0 ? 'block' : 'none'; dom.resolveButton.disabled = !actionsExist || gmIsProcessing; dom.messageInput.disabled = gmIsProcessing || inLobby; dom.sendButton.disabled = gmIsProcessing || inLobby; @@ -303,10 +257,13 @@ export function initUI(state) { 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'); + if (finalMessage) { + const finalContentSpan = state.activeStream.contentElement; + finalContentSpan.innerHTML = renderMarkdown(finalMessage.content); + } } state.activeStream = null; audio.endStream();