mirror of
https://github.com/Nighthawk42/VDM.git
synced 2026-08-30 04:30:21 +00:00
Added markdown support, updated readme.
This commit is contained in:
+43
-37
@@ -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>
|
||||
@@ -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.
|
||||

|
||||
|
||||
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.
|
||||
|
||||
 <!-- 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,113 +46,65 @@ 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
+50
-167
@@ -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}'.")
|
||||
|
||||
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())
|
||||
)
|
||||
# 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()))
|
||||
|
||||
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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user