"""
Gradio-based web client for the MUD server.
This is the main entry point for the MUD client interface. It creates a
multi-tab Gradio interface by composing individual tab modules.
The application has been refactored into a modular structure:
- api_client.py: All backend API communication
- utils.py: Shared utility functions
- static/styles.css: Centralized CSS styling
- tabs/: Individual tab modules (login, game, settings, etc.)
Interface Structure:
Tab 1: Login - User authentication
Tab 2: Register - New account creation
Tab 3: Game - Main gameplay interface with auto-refresh
Tab 4: Settings - Password change and server control (admin only)
Tab 5: Database - Admin database viewer (admin/superuser only)
Tab 6: Ollama - Ollama server management and AI model control (admin/superuser only)
Tab 7: Help - Game instructions and command reference
Key Features:
- Per-user session state using gr.State (prevents cross-user contamination)
- Auto-refresh timer (3 seconds) for real-time game updates
- Role-based UI elements (admin features hidden for regular players)
- Chat system with support for say, yell, and whisper commands
- Modular architecture for easy maintenance and extensibility
State Management:
Each user has their own gr.State dictionary containing:
- session_id: UUID from successful login
- username: Player's username
- role: User role (player, worldbuilder, admin, superuser)
- logged_in: Boolean login status
API Communication:
All game operations communicate with the FastAPI backend via HTTP requests
through the api_client module.
Port Configuration:
--ui-port CLI argument: Specify exact port (no auto-discovery)
MUD_UI_PORT env var: Specify preferred port (will auto-discover if in use)
Default: 7860 (will auto-discover if in use)
"""
import os
import socket
import gradio as gr
from mud_server.admin_gradio.api.auth import AuthAPIClient
from mud_server.admin_gradio.tabs import (
database_tab,
game_tab,
help_tab,
login_tab,
ollama_tab,
register_tab,
settings_tab,
)
from mud_server.admin_gradio.ui.state import (
build_logged_in_state,
build_login_failed_state,
clear_session_state,
is_admin_role,
update_session_state,
)
from mud_server.admin_gradio.utils import create_session_state, load_css
# Create module-level API client instance for reuse
_auth_client = AuthAPIClient()
[docs]
def login(username: str, password: str, session_state: dict) -> tuple:
"""
Handle user login with password authentication.
Sends credentials to backend via AuthAPIClient, stores session data on success,
and returns updated UI state for tab visibility and user info display.
This function wraps the new AuthAPIClient.login() method and uses UI state
builders to maintain compatibility with the Gradio interface.
Migration Notes:
- Migrated from old api_client.py to new modular structure
- Uses AuthAPIClient for authentication
- Uses state builders (build_logged_in_state, build_login_failed_state)
- Updates session_state using update_session_state helper
- Returns tuple matching Gradio output expectations
Args:
username: Username to login with
password: Plain text password
session_state: User's session state dictionary
Returns:
Tuple of (session_state, login_result, clear_username, clear_password,
login_tab, register_tab, game_tab, settings_tab, db_tab, ollama_tab, help_tab)
Examples:
>>> session = {}
>>> result = login("alice", "password123", session)
>>> isinstance(result, tuple) and len(result) == 11
True
"""
# Call the new API client
api_result = _auth_client.login(username, password)
# Build UI state based on result
if api_result["success"]:
# Update session state with login data
session_state = update_session_state(
session_state,
session_id=api_result["data"]["session_id"],
username=api_result["data"]["username"],
role=api_result["data"]["role"],
)
# Check if user has admin access
has_admin_access = is_admin_role(api_result["data"]["role"])
# Build and return logged-in UI state
return build_logged_in_state(
session_state,
message=api_result["message"],
has_admin_access=has_admin_access,
)
else:
# Build and return login-failed UI state
return build_login_failed_state(session_state, api_result["message"])
[docs]
def logout(session_state: dict) -> tuple:
"""
Handle user logout and clean up session state.
Sends logout request to backend via AuthAPIClient, clears session data,
and returns updated UI state to hide game tabs and show login tabs.
This function wraps the new AuthAPIClient.logout() method and uses UI state
builders to maintain compatibility with the Gradio interface.
Migration Notes:
- Migrated from old api_client.py to new modular structure
- Uses AuthAPIClient for logout
- Uses clear_session_state helper to reset session
- Uses build_logged_out_state (via manual construction) for UI updates
- Returns tuple matching Gradio output expectations
Args:
session_state: User's session state dictionary
Returns:
Tuple of (session_state, message, blank, login_tab, register_tab,
game_tab, settings_tab, db_tab, ollama_tab, help_tab)
Examples:
>>> session = {"session_id": "abc123", "logged_in": True}
>>> result = logout(session)
>>> isinstance(result, tuple) and len(result) == 10
True
"""
# Extract session_id from session state
session_id = session_state.get("session_id")
# Call the new API client
api_result = _auth_client.logout(session_id=session_id)
# Clear session state
session_state = clear_session_state(session_state)
# Build and return logged-out UI state
# Format: (session_state, message, blank, tabs...)
return (
session_state,
api_result["message"],
"", # blank field
gr.update(visible=True), # login tab
gr.update(visible=True), # register tab
gr.update(visible=False), # game tab
gr.update(visible=False), # settings tab
gr.update(visible=False), # database tab
gr.update(visible=False), # ollama tab
gr.update(visible=False), # help tab
)
[docs]
def create_interface():
"""
Create and configure the complete Gradio web interface.
Builds the entire multi-tab UI by composing individual tab modules.
Each tab module is responsible for its own layout, components, and
event handlers. This function handles only top-level integration and
tab visibility control based on authentication state.
Returns:
gr.Blocks: Configured Gradio interface ready to launch
"""
# Load CSS from external file
custom_css = load_css("styles.css")
with gr.Blocks(title="MUD Client", theme=gr.themes.Soft(), css=custom_css) as interface:
gr.Markdown("# MUD Client")
gr.Markdown("A simple Multi-User Dungeon client")
# Session state (per-user)
session_state = gr.State(create_session_state())
with gr.Tabs():
# Create all tabs
(
login_tab_component,
login_btn,
login_username_input,
login_password_input,
login_output,
) = login_tab.create()
register_tab_component = register_tab.create()
game_tab_component, game_logout_btn = game_tab.create(session_state)
settings_tab_component = settings_tab.create(session_state)
database_tab_component = database_tab.create(session_state)
ollama_tab_component = ollama_tab.create(session_state)
help_tab_component = help_tab.create()
# Wire up login event handler
login_btn.click(
login,
inputs=[login_username_input, login_password_input, session_state],
outputs=[
session_state,
login_output,
login_username_input,
login_password_input,
login_tab_component,
register_tab_component,
game_tab_component,
settings_tab_component,
database_tab_component,
ollama_tab_component,
help_tab_component,
],
)
# Wire up logout button to update tab visibility
# This needs to be separate from the Game tab handler because tabs aren't defined yet there
def logout_and_hide_tabs(session_st):
"""
Handle logout button click and update tab visibility.
This is a separate handler from the one in the Game tab because
tab visibility components need to be wired at the top level after
all tabs are defined. Calls logout() and extracts the relevant
outputs for session state, message, and tab visibility updates.
Returns:
Tuple for Gradio outputs: (session_state, message, login_tab,
register_tab, game_tab, settings_tab, database_tab, ollama_tab, help_tab)
"""
result = logout(session_st)
return (
result[0],
result[1],
result[3],
result[4],
result[5],
result[6],
result[7],
result[8],
result[9],
)
game_logout_btn.click(
logout_and_hide_tabs,
inputs=[session_state],
outputs=[
session_state,
login_output,
login_tab_component,
register_tab_component,
game_tab_component,
settings_tab_component,
database_tab_component,
ollama_tab_component,
help_tab_component,
],
)
return interface
# ============================================================================
# PORT DISCOVERY
# ============================================================================
# These functions handle automatic port discovery for the Gradio UI client.
# This mirrors the API server's port discovery but uses the 7860-7899 range,
# which is the standard range for Gradio applications.
#
# Rationale for separate implementation (not shared with server.py):
# - Different default ports (7860 vs 8000)
# - Different port ranges to avoid conflicts
# - Keeps client and server modules independent
# - Allows running multiple instances without conflicts
# ============================================================================
# Default port for Gradio UI (standard Gradio default)
DEFAULT_UI_PORT = 7860
# Port range for UI auto-discovery (40 ports should be sufficient)
UI_PORT_RANGE_START = 7860
UI_PORT_RANGE_END = 7899
[docs]
def is_port_available(port: int, host: str = "0.0.0.0") -> bool:
"""
Check if a TCP port is available for binding on the specified host.
This function is identical to the one in server.py but kept separate
to maintain module independence. The Gradio UI client should be able
to function without depending on the API server module.
Technical Details:
- Uses a TCP socket (SOCK_STREAM) for the availability check
- Socket is automatically closed via context manager
- Handles various OSError conditions (EADDRINUSE, EACCES, etc.)
Args:
port: TCP port number to check (1-65535)
host: Host interface to check. Common values:
- "0.0.0.0": All interfaces (default)
- "127.0.0.1": Localhost only
Returns:
True if the port is available for binding, False otherwise
Example:
>>> is_port_available(7860)
True # Port 7860 is free for Gradio
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind((host, port))
return True
except OSError:
return False
[docs]
def find_available_port(
preferred_port: int = DEFAULT_UI_PORT,
host: str = "0.0.0.0",
range_start: int = UI_PORT_RANGE_START,
range_end: int = UI_PORT_RANGE_END,
) -> int | None:
"""
Find an available TCP port for the Gradio UI, starting with preferred port.
Implements sequential port scanning to find an available port:
1. Try the preferred port first (usually 7860)
2. If unavailable, scan 7860-7899 sequentially
3. Return None if no ports available (caller handles error)
Args:
preferred_port: The first port to try. Defaults to 7860 (Gradio default).
host: Host interface to check availability on. Defaults to "0.0.0.0".
range_start: First port in scan range (inclusive). Defaults to 7860.
range_end: Last port in scan range (inclusive). Defaults to 7899.
Returns:
int: An available port number within the range
None: If no ports are available (all 40 ports in use)
Example:
>>> find_available_port(7860)
7860 # Normal case - default port available
>>> find_available_port(7860) # 7860 in use
7861 # Returns next available port
"""
# Try preferred port first (common case - it's usually available)
if is_port_available(preferred_port, host):
return preferred_port
# Sequential scan through the Gradio port range
for port in range(range_start, range_end + 1):
if port != preferred_port and is_port_available(port, host):
return port
# No ports available in range
return None
# ============================================================================
# CLIENT STARTUP
# ============================================================================
[docs]
def launch_client(
host: str | None = None,
port: int | None = None,
auto_discover: bool = True,
) -> None:
"""
Launch the Gradio web client with configurable host and port.
This is the main entry point for running the Gradio-based web interface.
It handles configuration resolution and implements automatic port discovery
to avoid "address already in use" errors.
Configuration Resolution Order:
For both host and port, configuration is resolved in this priority:
1. Explicit function parameter (highest priority)
2. Environment variable (MUD_UI_HOST, MUD_UI_PORT)
3. Default value (0.0.0.0:7860)
Auto-Discovery Behavior:
When auto_discover=True (default):
- If port 7860 is in use, scans 7860-7899 for an available port
- Prints a message when using an alternate port
- Raises RuntimeError only if ALL ports in range are unavailable
When auto_discover=False:
- Fails immediately if the specified port is unavailable
- Useful when port must match external configuration (proxy, etc.)
Args:
host: Network interface to bind to. Common values:
- None: Use MUD_UI_HOST env var, or "0.0.0.0" (all interfaces)
- "0.0.0.0": Accept connections from any network interface
- "127.0.0.1": Accept only local connections (localhost)
port: TCP port number for the web UI. Values:
- None: Use MUD_UI_PORT env var, or 7860 (Gradio default)
- Integer: Use this specific port (subject to auto_discover)
auto_discover: Enable automatic port discovery. Defaults to True.
Raises:
RuntimeError: When auto_discover=True but no port is available in
the 7860-7899 range.
OSError: When auto_discover=False and the specified port is in use.
Example:
# Default configuration
launch_client() # Uses 0.0.0.0:7860 with auto-discovery
# Custom port for development
launch_client(port=8080)
# Local development only
launch_client(host="127.0.0.1")
Note:
This function blocks until the Gradio server is stopped.
The interface is accessible at http://{host}:{port} once started.
"""
# ========================================================================
# CONFIGURATION RESOLUTION
# ========================================================================
# Resolve host: parameter > env var > default
if host is None:
host = os.getenv("MUD_UI_HOST", "0.0.0.0")
# Resolve port: parameter > env var > default
if port is None:
port = int(os.getenv("MUD_UI_PORT", DEFAULT_UI_PORT))
# ========================================================================
# PORT AUTO-DISCOVERY
# ========================================================================
if auto_discover:
available_port = find_available_port(port, host)
if available_port is None:
raise RuntimeError(
f"No available port found in range {UI_PORT_RANGE_START}-{UI_PORT_RANGE_END}. "
"This may indicate too many Gradio instances running."
)
if available_port != port:
print(f"Port {port} is in use. Using port {available_port} instead.")
port = available_port
# ========================================================================
# GRADIO STARTUP
# ========================================================================
print(f"Starting MUD client on {host}:{port}")
# Create the Gradio interface
interface = create_interface()
# Launch the Gradio server
# This blocks until the server is stopped (Ctrl+C or programmatic stop)
interface.launch(
server_name=host,
server_port=port,
share=False, # Don't create a public Gradio link
show_error=True, # Display errors in the UI for debugging
)
if __name__ == "__main__":
launch_client()