Added markdown support, updated readme.

This commit is contained in:
Nighthawk
2025-09-05 06:18:36 -04:00
parent 96016230de
commit 21ecc7dac6
7 changed files with 195 additions and 363 deletions
+43 -37
View File
@@ -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 <v> tag with their name. All text outside of a <v> 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 `<v>` tag with their name. All text outside of a `<v>` 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. <v name="Gandalf">You are late.</v> He says,
**EXAMPLE**:
The old man looks up from his book with a sigh. <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.
# 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 <thinking> block and a <RESPONSE> block.
**CRITICAL OUTPUT FORMAT**: You MUST format your entire output in two parts using XML-style tags:
a `<thinking>` block for your reasoning and a `<RESPONSE>` block for your narration to the players.
Example:
<thinking>The player wants to inspect the chest. I'll make the lock unusual.</thinking>
**EXAMPLE**:
<thinking>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.</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>
circular indentation with three strange runes carved around it. The air grows colder near the chest.</RESPONSE>
+57 -89
View File
@@ -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) <!-- Replace with a real screenshot URL -->
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 `<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
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.
---
+51 -168
View File
@@ -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,
+2 -10
View File
@@ -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."""
+3 -2
View File
@@ -10,11 +10,12 @@
<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 - Virtual Dungeon Master - Version 0.1 [Alpha]</div>
<div id="app-title">VDM</div>
<button id="theme-toggle" title="Toggle Light/Dark Mode">💡</button>
</header>
@@ -108,7 +109,7 @@
<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="mic-button" type="button" title="Hold to Talk">🎤</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>
+25
View File
@@ -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;
}
+14 -57
View File
@@ -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<ApiModule>) => 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, '<code>$1</code>');
contentHTML = contentHTML.replace(/\*\*([^\*]+)\*\*/g, '<strong>$1</strong>');
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();