from __future__ import annotations import json import os import queue import random import threading import time import warnings from collections.abc import Mapping from datetime import datetime, timezone from pathlib import Path from typing import Any from uuid import uuid4 from starlette.exceptions import StarletteDeprecationWarning warnings.filterwarnings( "ignore", message=r".*HTTP_422_UNPROCESSABLE_ENTITY.*HTTP_422_UNPROCESSABLE_CONTENT.*", category=StarletteDeprecationWarning, ) import gradio as gr from gradio_client import Client from huggingface_hub import upload_file import numpy as np from dotenv import load_dotenv from components import Board, NeonToast from dod_logging import log_bot, log_error, log_info, quiet_external_stdout from game_manager import ( BOT_NAME, LLM_API_KEY, APP_UI, CRISES_DATABASE, SYNC_RATE_LEADERBOARD_SECONDS, SYNC_RATE_PLAYER_SECONDS, SYNC_RATE_SPECTATOR_SECONDS, TICK_LOBBY_WARMUP_SECONDS, TICK_RATE_SERVER_SECONDS, GameState, ServerResponse, get_optional_env_secret, global_server, llm_queue, ) from inference_mapper import EndpointConfig, get_endpoint_chain, mark_endpoint_failed, mark_endpoint_success from manual import render_how_to_play_html from prompts import BOT_SYSTEM_PROMPT, DIRECTOR_SYSTEM_PROMPT load_dotenv(override=True) def sanitize_hf_token_environment() -> None: """Keep Gradio OAuth from using app dataset credentials or stale .env tokens.""" os.environ.pop("HF_TOKEN", None) sanitize_hf_token_environment() # Highest safe seed value accepted by llama.cpp's int32 seed path. MAX_SEED = np.iinfo(np.int32).max # Development switch that skips model download/loading during smoke tests. LLM_DISABLED: bool = os.getenv("DOD_DISABLE_LLM", "").lower() in {"1", "true", "yes"} # Development switch that skips Director voice synthesis while keeping quote text. TTS_DOWNLOAD_DISABLED: bool = os.getenv("DOD_DISABLE_TTS", "").lower() in {"1", "true", "yes"} def randomize_seed_fn(generation_seed: int, randomize_seed: bool) -> int: """Return either the provided seed or a randomized int32-safe seed. Args: generation_seed: Existing seed to reuse when randomization is disabled. randomize_seed: Whether a new seed should be generated. Returns: Seed value suitable for llama.cpp generation calls. """ if randomize_seed: generation_seed = random.randint(0, MAX_SEED) return generation_seed tts_audio_queue: queue.Queue[dict[str, Any]] = queue.Queue() HF_AUTH_PLAYER_NAMES: set[str] = set() DOD_AGENT_TRACE_ENABLED: bool = os.getenv("DOD_ENABLE_AGENT_TRACES", "true").lower() in {"1", "true", "yes", "on"} DOD_AGENT_TRACE_FILE = Path(os.getenv("DOD_AGENT_TRACE_FILE", "dod_agent_traces.jsonl")).expanduser() DOD_AGENT_TRACE_UPLOAD_ENABLED: bool = os.getenv("DOD_UPLOAD_AGENT_TRACES", "true").lower() in {"1", "true", "yes", "on"} DOD_AGENT_TRACE_DATASET_REPO_ID = os.getenv("DOD_AGENT_TRACE_DATASET_REPO_ID", "build-small-hackathon/dod-agent-traces").strip() DOD_AGENT_TRACE_DATASET_PATH = os.getenv("DOD_AGENT_TRACE_DATASET_PATH", "dod_agent_traces.jsonl").strip() DOD_TRACE_MODEL_LABEL = "NVIDIA-Nemotron-3-Nano-4B-GGUF" agent_trace_lock = threading.Lock() agent_trace_upload_lock = threading.Lock() agent_trace_upload_pending = False def schedule_dod_agent_trace_upload() -> None: """Upload the local trace JSONL to the configured Hugging Face Dataset in background.""" if not DOD_AGENT_TRACE_UPLOAD_ENABLED or not DOD_AGENT_TRACE_DATASET_REPO_ID: return threading.Thread(target=upload_dod_agent_trace_file, daemon=True).start() def upload_dod_agent_trace_file() -> None: """Sync agent traces to the Hub using HF_TOKEN_DATASET without blocking gameplay.""" global agent_trace_upload_pending if not DOD_AGENT_TRACE_FILE.exists(): return dataset_token = get_optional_env_secret("HF_TOKEN_DATASET") if not dataset_token: log_info("[Agent Trace] HF_TOKEN_DATASET is not set; skipping trace dataset upload.", flush=True) return if not agent_trace_upload_lock.acquire(blocking=False): agent_trace_upload_pending = True return try: while True: agent_trace_upload_pending = False upload_file( path_or_fileobj=str(DOD_AGENT_TRACE_FILE), path_in_repo=DOD_AGENT_TRACE_DATASET_PATH, repo_id=DOD_AGENT_TRACE_DATASET_REPO_ID, repo_type="dataset", token=dataset_token, commit_message="Update DOD agent traces", ) if not agent_trace_upload_pending: break except Exception as exc: log_error(f"[Agent Trace] Dataset upload failed: {exc}", flush=True) finally: agent_trace_upload_lock.release() def append_dod_agent_record( event_name: str, input_payload: dict[str, Any], output_payload: dict[str, Any], status: str, metadata: dict[str, Any] | None = None, error: str = "", ) -> None: """Append one DOD agent trace without ever interrupting gameplay.""" if not DOD_AGENT_TRACE_ENABLED: return trace_record = { "id": uuid4().hex, "timestamp": datetime.now(timezone.utc).isoformat(), "event": event_name, "model": DOD_TRACE_MODEL_LABEL, "status": status, "input": input_payload, "output": output_payload, "metadata": metadata or {}, } if error: trace_record["error"] = error try: DOD_AGENT_TRACE_FILE.parent.mkdir(parents=True, exist_ok=True) with agent_trace_lock: with DOD_AGENT_TRACE_FILE.open("a", encoding="utf-8") as trace_file: trace_file.write(json.dumps(trace_record, ensure_ascii=False) + "\n") schedule_dod_agent_trace_upload() except Exception: pass def create_llm_client( endpoint: EndpointConfig, timeout_override: float | None = None, ip_token: str = "", ) -> Client: """Create an isolated Gradio client for one LLM request. Args: endpoint: Resolved endpoint configuration from the mapper. timeout_override: Optional HTTP timeout for warmup calls. ip_token: Hugging Face ZeroGPU IP token forwarded from the user request. Returns: Gradio client connected to the endpoint URL. """ url = endpoint["url"] timeout = float(timeout_override if timeout_override is not None else endpoint.get("timeout", 120.0)) headers = {"x-ip-token": ip_token} if ip_token else None hf_space_token = get_optional_env_secret("HF_SPACE_TOKEN") log_info( f"[LLM Client] Connecting to {endpoint.get('name', 'endpoint')}: {url} " f"(zero_gpu_token={'yes' if ip_token else 'no'})", flush=True, ) with quiet_external_stdout(): return Client(url, token=hf_space_token or None, headers=headers, httpx_kwargs={"timeout": timeout}) def predict_llm( system_prompt: str, user_payload: str, temperature: float, grammar_schema: str, use_warmup_timeout: bool = False, ip_token: str = "", ) -> str: """Call the mapped LLM primary endpoint and fallback endpoints. Args: system_prompt: System prompt sent to the inference service. user_payload: Serialized user payload. temperature: Generation temperature. grammar_schema: Serialized JSON schema passed to the service. ip_token: Hugging Face ZeroGPU IP token forwarded from the user request. Returns: Raw model response string. """ last_error: Exception | None = None for endpoint in get_endpoint_chain("llm"): mode = endpoint.get("mode", "gradio") url = endpoint.get("url", "") api_name = endpoint.get("api_name") or "/generate_inference" if mode != "gradio": log_error(f"[LLM Client] Skipping unsupported LLM mode '{mode}' for {url}", flush=True) continue try: timeout_override = float(endpoint.get("warmup_timeout", endpoint.get("timeout", 120.0))) if use_warmup_timeout else None client = create_llm_client(endpoint, timeout_override, ip_token) log_info(f"[LLM Client] Calling {endpoint.get('name', 'endpoint')} via Gradio: {url}", flush=True) result = client.predict( LLM_API_KEY, system_prompt, user_payload, temperature, grammar_schema, api_name=api_name, ) mark_endpoint_success("llm", endpoint) return result except Exception as exc: last_error = exc mark_endpoint_failed("llm", endpoint, str(exc)) log_info(f"[LLM Client] Endpoint failed ({url}): {exc}", flush=True) raise RuntimeError(f"No LLM endpoint succeeded: {last_error}") GLOBAL_CSS = """ html { height: 100vh !important; max-height: 100vh !important; overflow: hidden !important; background-color: #070913 !important; background: #070913 !important; margin: 0 !important; padding: 0 !important; } body { height: 100vh !important; max-height: 100vh !important; overflow: hidden !important; background-color: transparent !important; background: transparent !important; margin: 0 !important; padding: 0 !important; } .gradio-container { height: 100vh !important; max-height: 100vh !important; overflow-y: auto !important; background-color: transparent !important; background: transparent !important; scrollbar-width: thin; scrollbar-color: #44345d transparent; } .gradio-container::-webkit-scrollbar { width: 6px; } .gradio-container::-webkit-scrollbar-track { background: transparent; } .gradio-container::-webkit-scrollbar-thumb { background: #44345d; border-radius: 4px; } #bg_canvas { position: fixed !important; top: 0 !important; left: 0 !important; width: 100vw !important; height: 100vh !important; z-index: -1 !important; pointer-events: none !important; } .glass-lobby { background: rgba(20, 15, 30, 0.6) !important; backdrop-filter: blur(15px) !important; border: 1px solid rgba(255, 255, 255, 0.1) !important; border-radius: 16px !important; padding: 30px !important; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5) !important; max-width: 600px !important; width: 100% !important; margin: 40px auto 20px auto !important; box-sizing: border-box !important; } .lobby-logo { max-width: 150px; margin: 0 auto 20px auto; display: block; /*filter: drop-shadow(0 0 10px rgba(0, 243, 255, 0.5));*/ } .dod-heading { text-align: center !important; color: #ffffff !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif !important; font-size: 26px !important; font-weight: 800 !important; line-height: 1.18 !important; letter-spacing: 0 !important; text-rendering: optimizeLegibility !important; -webkit-font-smoothing: antialiased !important; font-kerning: normal !important; text-shadow: 0 0 10px rgba(0, 243, 255, 0.45) !important; margin: 0 !important; width: 100% !important; } #global_lang_bar { position: relative !important; z-index: 50 !important; width: min(100%, 1220px) !important; max-width: 1220px !important; margin: 6px auto -2px auto !important; padding: 0 18px !important; display: flex !important; justify-content: flex-end !important; align-items: center !important; box-sizing: border-box !important; background: transparent !important; border: 0 !important; min-height: 0 !important; } #global_lang_bar > div, #global_lang_bar .form { flex: 0 0 auto !important; width: auto !important; min-width: 0 !important; max-width: max-content !important; background: transparent !important; border: 0 !important; padding: 0 !important; } #global_lang_bar .language-switch { flex: 0 0 auto !important; width: auto !important; min-width: 0 !important; max-width: max-content !important; padding: 0 !important; margin: 0 !important; border: 0 !important; background: transparent !important; box-shadow: none !important; overflow: visible !important; } #global_lang_bar .language-switch > label, #global_lang_bar .language-switch .block-label, #global_lang_bar .language-switch .label-container, #global_lang_bar .language-switch .label, #global_lang_bar .language-switch .label-wrap, #global_lang_bar .language-switch .svelte-1gfkn6j, #global_lang_bar .language-switch legend { display: none !important; } #global_lang_bar .language-switch fieldset, #global_lang_bar .language-switch .container, #global_lang_bar .language-switch .input-container, #global_lang_bar .language-switch .wrap { width: auto !important; min-width: 0 !important; max-width: max-content !important; margin: 0 !important; border: 0 !important; background: transparent !important; box-shadow: none !important; } #global_lang_bar .language-switch .wrap, #global_lang_bar .language-switch .radio-group { display: flex !important; align-items: center !important; gap: 0 !important; width: auto !important; min-width: 0 !important; max-width: max-content !important; padding: 3px !important; border: 1px solid rgba(0, 243, 255, 0.38) !important; border-radius: 999px !important; background: rgba(7, 20, 38, 0.82) !important; box-shadow: 0 0 16px rgba(0, 243, 255, 0.14) !important; overflow: hidden !important; } #global_lang_bar .language-switch label { margin: 0 !important; border-radius: 999px !important; background: transparent !important; border: 0 !important; box-shadow: none !important; } #global_lang_bar .language-switch input[type="radio"] { position: absolute !important; opacity: 0 !important; pointer-events: none !important; } #global_lang_bar .language-switch label span { min-width: 58px !important; padding: 6px 11px !important; border-radius: 999px !important; color: #cbd5e0 !important; font-size: 12px !important; font-weight: 900 !important; text-align: center !important; text-transform: uppercase !important; } #global_lang_bar .language-switch input:checked + span, #global_lang_bar .language-switch label:has(input:checked) span, #global_lang_bar .language-switch label.selected span { background: linear-gradient(180deg, #00f3ff 0%, #00a8ff 100%) !important; color: #070913 !important; box-shadow: 0 0 12px rgba(0, 243, 255, 0.38) !important; } #lobby-title, #lobby-title h1, #lobby-title span, #lobby-title .prose { text-align: center !important; display: block !important; width: 100% !important; margin: 0 auto !important; } /* 1. Primary Game Button (Chunky 3D Bevel with physical active click) */ .glass-lobby button.primary { background: linear-gradient(180deg, #00f3ff 0%, #00a8ff 100%) !important; color: #070913 !important; font-family: "Segoe UI", Arial, Helvetica, sans-serif !important; font-size: 14px !important; font-weight: 900 !important; line-height: 1.15 !important; letter-spacing: 0.8px !important; text-transform: uppercase !important; text-rendering: geometricPrecision !important; border-top: 3px solid #ffffff !important; border-left: 3px solid #00f3ff !important; border-bottom: 6px solid #005c8a !important; border-right: 6px solid #004566 !important; border-radius: 8px !important; box-shadow: 0 6px 15px rgba(0, 243, 255, 0.35) !important; transition: all 0.1s ease !important; transform: translateY(0px) !important; } .glass-lobby button.primary:active { transform: translateY(3px) !important; border-bottom: 2px solid #005c8a !important; border-right: 2px solid #004566 !important; box-shadow: 0 2px 5px rgba(0, 243, 255, 0.25) !important; } .manual-page { max-width: 1180px !important; margin: 0 auto !important; padding: 18px !important; color: #ffffff !important; font-family: "Segoe UI", Arial, Helvetica, sans-serif !important; } .manual-hero { border: 1.5px solid rgba(0, 243, 255, 0.5) !important; border-radius: 10px !important; padding: 22px !important; background: radial-gradient(circle at top, rgba(0, 243, 255, 0.14), rgba(7, 20, 38, 0.9) 55%, rgba(5, 6, 11, 0.95)) !important; box-shadow: 0 0 22px rgba(0, 243, 255, 0.15), inset 0 0 18px rgba(0, 0, 0, 0.45) !important; } .manual-kicker { color: #00f3ff !important; font-size: 12px !important; font-weight: 900 !important; text-transform: uppercase !important; letter-spacing: 0.8px !important; } .manual-hero h2 { margin: 6px 0 8px 0 !important; color: #ffffff !important; font-size: 28px !important; } .manual-hero p, .manual-panel p { color: #cbd5e0 !important; font-size: 14px !important; line-height: 1.55 !important; } .manual-grid { display: grid !important; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)) !important; gap: 12px !important; margin-top: 12px !important; } .manual-panel { border: 1px solid rgba(255, 255, 255, 0.12) !important; border-radius: 8px !important; background: rgba(7, 20, 38, 0.82) !important; padding: 16px !important; box-shadow: inset 0 0 16px rgba(0, 0, 0, 0.32) !important; } .manual-wide { margin-top: 12px !important; } .manual-panel h3 { margin: 0 0 9px 0 !important; color: #00f3ff !important; font-size: 17px !important; } .manual-list { margin: 0 !important; padding-left: 20px !important; color: #cbd5e0 !important; font-size: 14px !important; line-height: 1.55 !important; } .manual-stack-row { display: flex !important; flex-wrap: wrap !important; gap: 8px !important; } .manual-stack-chip { display: inline-flex !important; align-items: center !important; min-height: 28px !important; padding: 4px 10px !important; border: 2px solid rgba(255, 255, 255, 0.75) !important; border-radius: 6px !important; color: #ffffff !important; font-size: 12px !important; font-weight: 900 !important; text-transform: uppercase !important; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85) !important; } .manual-card-gallery { display: flex !important; flex-wrap: wrap !important; gap: 12px !important; align-items: flex-start !important; margin-top: 14px !important; } .manual-card-wrap { width: 116px !important; display: flex !important; flex-direction: column !important; align-items: center !important; gap: 6px !important; } .manual-card { position: relative !important; width: 100px !important; height: 140px !important; border-radius: 8px !important; border: 3.5px solid #ffffff !important; padding: 8px 6px !important; box-sizing: border-box !important; display: flex !important; flex-direction: column !important; gap: 5px !important; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.35) !important; } .manual-card-green { background-color: #2ecc71 !important; } .manual-card-blue { background-color: #3498db !important; } .manual-card-red { background-color: #e74c3c !important; } .manual-card-yellow { background-color: #f1c40f !important; color: #111115 !important; } .manual-card-wild { background-color: #111115 !important; } .manual-card-badge { position: absolute !important; top: 4px !important; left: 4px !important; background: rgba(0, 0, 0, 0.65) !important; border: 1px solid rgba(255, 255, 255, 0.4) !important; border-radius: 4px !important; color: #ffffff !important; font-size: 10px !important; font-weight: 900 !important; padding: 2px 5px !important; } .manual-card-stack { text-align: right !important; min-height: 10px !important; font-size: 7px !important; font-weight: 900 !important; color: rgba(255, 255, 255, 0.78) !important; text-transform: uppercase !important; } .manual-card-yellow .manual-card-stack { color: rgba(17, 17, 21, 0.7) !important; } .manual-card-diamond { width: 50px !important; height: 50px !important; margin: 6px auto 5px auto !important; border-radius: 8px !important; background: #ffffff !important; transform: rotate(45deg) !important; display: flex !important; align-items: center !important; justify-content: center !important; box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2) !important; } .manual-card-diamond span { transform: rotate(-45deg) !important; font-size: 23px !important; color: #111115 !important; } .manual-card-title { min-height: 22px !important; display: flex !important; align-items: center !important; justify-content: center !important; text-align: center !important; font-size: 8px !important; font-weight: 900 !important; line-height: 1.08 !important; text-transform: uppercase !important; overflow-wrap: anywhere !important; } .manual-text-light { color: #ffffff !important; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.8) !important; } .manual-text-dark { color: #111115 !important; text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35) !important; } .manual-card-stats { margin-top: auto !important; display: flex !important; gap: 2px !important; border-top: 1.5px dashed rgba(255, 255, 255, 0.45) !important; padding-top: 3px !important; font-size: 8px !important; font-weight: 900 !important; line-height: 1 !important; white-space: nowrap !important; } .manual-card-stats span { flex: 1 1 0 !important; min-width: 0 !important; padding: 2px 1px !important; border-radius: 3px !important; background: rgba(0, 0, 0, 0.62) !important; text-align: center !important; } .manual-stat-good { color: #d9ff5a !important; text-shadow: 0 1px 2px rgba(0, 0, 0, 1) !important; } .manual-stat-bad { color: #ffc7d1 !important; text-shadow: 0 1px 2px rgba(0, 0, 0, 1) !important; } .manual-card-note { color: #cbd5e0 !important; font-size: 11px !important; font-weight: 800 !important; text-align: center !important; } .manual-tip { margin-top: 12px !important; padding: 12px 14px !important; border-left: 3px solid #00f3ff !important; border-radius: 6px !important; background: rgba(0, 243, 255, 0.08) !important; color: #ffffff !important; font-weight: 800 !important; } /* 2. Text inputs styled as engraved CRT computer terminals */ .glass-lobby input[type="text"] { background-color: #05060b !important; color: #00f3ff !important; font-family: "Courier New", Courier, monospace !important; font-weight: bold !important; border-radius: 6px !important; border-top: 2.5px solid #070913 !important; border-left: 2.5px solid #070913 !important; border-bottom: 2.5px solid #3b426f !important; border-right: 2.5px solid #3b426f !important; box-shadow: inset 4px 4px 10px rgba(0, 0, 0, 0.85) !important; } .glass-lobby input[type="text"]:focus { border-color: #00f3ff !important; box-shadow: inset 4px 4px 10px rgba(0, 0, 0, 0.85), 0 0 8px rgba(0, 243, 255, 0.4) !important; } /* 3. Top Navigation Tabs styled as hardware console selector buttons */ #main_tabs > .tab-nav { background-color: #0b0d19 !important; border-bottom: 3px solid #232844 !important; padding: 5px 15px 0 15px !important; display: flex !important; gap: 6px !important; } #main_tabs > .tab-nav > button { background-color: #111424 !important; color: #a0aec0 !important; border: 2px solid #232844 !important; border-bottom: none !important; border-top-left-radius: 8px !important; border-top-right-radius: 8px !important; padding: 8px 18px !important; font-weight: bold !important; font-size: 13px !important; box-shadow: inset 0 -4px 8px rgba(0, 0, 0, 0.5) !important; transition: all 0.2s ease !important; } #main_tabs > .tab-nav > button:hover { color: #ffffff !important; background-color: #161a35 !important; } #main_tabs > .tab-nav > button.selected { background-color: #1a1e3a !important; color: #00f3ff !important; border-color: #00f3ff !important; text-shadow: 0 0 8px rgba(0, 243, 255, 0.5) !important; box-shadow: none !important; transform: translateY(-2px) !important; position: relative !important; z-index: 5 !important; } #leave_queue_btn { max-width: 180px !important; margin: 8px auto 0 auto !important; background-color: transparent !important; color: #ff6b9a !important; border: 1px solid #ff0055 !important; border-radius: 6px !important; font-weight: bold !important; } #leave_queue_btn:hover { background-color: #ff0055 !important; color: #ffffff !important; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .game-spinner { width: 16px; height: 16px; border: 3px solid rgba(0, 243, 255, 0.15); border-top: 3px solid #00f3ff; /* Cyan active indicator */ border-radius: 50%; animation: spin 1s linear infinite !important; display: inline-block; flex-shrink: 0; vertical-align: middle; box-shadow: 0 0 8px rgba(0, 243, 255, 0.4); margin-left: 10px; margin-right: 8px; } """ GLOBAL_JS = """ (n, l, h) => { window.dodAudioMuted = true; localStorage.setItem('dod_audio_muted', 'true'); window.dodLobbyMusic = window.dodLobbyMusic || { audio: null, lastState: null, shouldPlay: true, autoplayWarningShown: false, init() { if (this.audio) return; this.audio = new Audio("/gradio_api/file=assets/main.ogg"); this.audio.loop = true; this.audio.preload = "auto"; this.audio.volume = 0.28; }, canPlayInState(state) { if (!state) return true; if (state.restart_countdown) return false; const players = Array.isArray(state.players) ? state.players : []; const viewerId = state.viewer_id || localStorage.getItem("uno_name") || ""; const viewerInMatch = viewerId !== "" && players.indexOf(viewerId) !== -1; const tabButtons = document.querySelectorAll("#main_tabs > .tab-nav > button"); const matchTabSelected = !!(tabButtons && tabButtons[1] && tabButtons[1].classList.contains("selected")); return !(state.game_started && (viewerInMatch || matchTabSelected)); }, sync(state) { this.init(); this.lastState = state || null; this.shouldPlay = this.canPlayInState(state); if (window.dodAudioMuted || !this.shouldPlay) { this.pause(); return; } this.play(); }, play() { this.init(); if (!this.audio || window.dodAudioMuted || !this.shouldPlay) return; const promise = this.audio.play(); if (promise && typeof promise.catch === "function") { promise.catch((error) => { if (!this.autoplayWarningShown) { console.warn("Lobby music autoplay is waiting for a browser interaction.", error); this.autoplayWarningShown = true; } }); } }, pause() { if (this.audio && !this.audio.paused) { this.audio.pause(); } } }; window.dodLobbyMusic.sync(null); window.addEventListener("click", () => { if (window.dodLobbyMusic) { window.dodLobbyMusic.sync(window.dodLobbyMusic.lastState); } }, { passive: true }); window.gameAudio = { ctx: null, init() { if (this.ctx) return; const AudioContext = window.AudioContext || window.webkitAudioContext; if (AudioContext) { this.ctx = new AudioContext(); } }, play(type) { if (window.dodAudioMuted) return; this.init(); if (!this.ctx) return; if (this.ctx.state === 'suspended') { this.ctx.resume(); } const now = this.ctx.currentTime; try { switch(type) { case 'play': { let osc = this.ctx.createOscillator(); let gain = this.ctx.createGain(); osc.type = 'triangle'; osc.frequency.setValueAtTime(500, now); osc.frequency.exponentialRampToValueAtTime(150, now + 0.08); gain.gain.setValueAtTime(0.12, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now); osc.stop(now + 0.08); break; } case 'attack': { let osc = this.ctx.createOscillator(); let gain = this.ctx.createGain(); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(880, now); osc.frequency.exponentialRampToValueAtTime(110, now + 0.22); gain.gain.setValueAtTime(0.07, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.22); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now); osc.stop(now + 0.22); break; } case 'draw': { let osc = this.ctx.createOscillator(); let gain = this.ctx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(120, now); osc.frequency.exponentialRampToValueAtTime(450, now + 0.15); gain.gain.setValueAtTime(0.1, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now); osc.stop(now + 0.15); break; } case 'shout': { let osc1 = this.ctx.createOscillator(); let osc2 = this.ctx.createOscillator(); let gain = this.ctx.createGain(); osc1.type = 'sine'; osc1.frequency.setValueAtTime(523.25, now); osc2.type = 'sine'; osc2.frequency.setValueAtTime(659.25, now + 0.08); gain.gain.setValueAtTime(0.08, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.4); osc1.connect(gain); osc2.connect(gain); gain.connect(this.ctx.destination); osc1.start(now); osc1.stop(now + 0.4); osc2.start(now + 0.08); osc2.stop(now + 0.4); break; } case 'tick': { let osc = this.ctx.createOscillator(); let gain = this.ctx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(900, now); gain.gain.setValueAtTime(0.03, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.02); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now); osc.stop(now + 0.02); break; } case 'warning': { let osc = this.ctx.createOscillator(); let gain = this.ctx.createGain(); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(170, now); osc.frequency.linearRampToValueAtTime(160, now + 0.22); gain.gain.setValueAtTime(0.05, now); gain.gain.linearRampToValueAtTime(0.001, now + 0.22); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now); osc.stop(now + 0.22); break; } case 'victory': { const notes = [523.25, 659.25, 783.99, 1046.50]; notes.forEach((freq, idx) => { let osc = this.ctx.createOscillator(); let gain = this.ctx.createGain(); osc.type = 'square'; osc.frequency.setValueAtTime(freq, now + idx * 0.09); gain.gain.setValueAtTime(0.05, now + idx * 0.09); gain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.09 + 0.22); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now + idx * 0.09); osc.stop(now + idx * 0.09 + 0.22); }); break; } case 'game_over': { let osc = this.ctx.createOscillator(); let gain = this.ctx.createGain(); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(220, now); osc.frequency.exponentialRampToValueAtTime(80, now + 0.9); gain.gain.setValueAtTime(0.07, now); gain.gain.linearRampToValueAtTime(0.001, now + 0.9); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now); osc.stop(now + 0.9); break; } } } catch (e) { console.warn("Audio play failed:", e); } } }; function applyTransparency() { const body = document.body; const gradioApp = document.querySelector('gradio-app'); const container = document.querySelector('.gradio-container'); if (body) { body.style.backgroundColor = 'transparent'; body.style.margin = '0'; } if (gradioApp) { gradioApp.style.background = 'transparent'; } if (container) { container.style.background = 'transparent'; container.style.boxShadow = 'none'; } } applyTransparency(); function initAnimation() { const canvas = document.getElementById('bg_canvas'); if (!canvas) { requestAnimationFrame(initAnimation); return; } const ctx = canvas.getContext('2d'); let w, h; let animationId; function resize() { const rawW = window.innerWidth; const rawH = window.innerHeight; w = rawW; h = Math.min(rawH, 1080); const ratio = window.devicePixelRatio || 1; canvas.width = Math.round(w * ratio); canvas.height = Math.round(h * ratio); canvas.style.width = '100vw'; canvas.style.height = '100vh'; ctx.scale(ratio, ratio); } window.addEventListener('resize', resize); resize(); const colors = { "green": "rgba(46, 204, 113, ", "blue": "rgba(52, 152, 219, ", "red": "rgba(231, 76, 60, ", "yellow": "rgba(241, 196, 15, " }; const imageFiles = [ "icon_super.png", "icon_fix.png", "icon_refactor.png", "icon_tech_debt.png", "icon_docs.png", "icon_patch.png", "icon_stack_overflow.png", "icon_spaghetti.png", "icon_blind_pr.png", "icon_bug.png", "icon_skip.png", "icon_reverse.png", "icon_attack.png", "icon_wild.png", "icon_nuke.png", "icon_gradio.png", "icon_nvidia.png", "icon_openbmb.png", "icon_openai.png", "icon_modal.png", "icon_huggingface.png" ]; const fallbackSymbols = [ "🚀", "🩹", "🧹", "💸", "📝", "🔨", "📋", "🍝", "🙈", "🐛", "🛑", "🔄", "➕", "🎨", "☢️", "🤗", "💚" ]; const loadedImages = []; let imagesReady = false; let loadedCount = 0; imageFiles.forEach((filename, idx) => { const img = new Image(); img.src = "/gradio_api/file=assets/" + filename; img.onload = () => { loadedCount++; if (loadedCount === imageFiles.length) { imagesReady = true; } }; img.onerror = () => { loadedCount++; if (loadedCount === imageFiles.length) { imagesReady = true; } }; loadedImages[idx] = img; }); function drawRoundedRect(ctx, x, y, width, height, radius) { ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.lineTo(x + width - radius, y); ctx.arcTo(x + width, y, x + width, y + radius, radius); ctx.lineTo(x + width, y + height - radius); ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius); ctx.lineTo(x + radius, y + height); ctx.arcTo(x, y + height, x, y + height - radius, radius); ctx.lineTo(x, y + radius); ctx.arcTo(x, y, x + radius, y, radius); ctx.closePath(); } const assetColors = [ "red", "green", "blue", "yellow", "green", "red", "blue", "yellow", "red", "green", "yellow", "blue", "red", "blue", "red", "yellow", "green", "blue", "black", "green", "black", ]; class FloatingCard { constructor() { this.reset(true); } reset(initial = false) { this.w = 60; this.h = 84; this.x = Math.random() * w; this.y = initial ? Math.random() * h : h + 100; this.vx = (Math.random() - 0.5) * 0.4; this.vy = -(0.3 + Math.random() * 0.6); this.angle = Math.random() * Math.PI * 2; this.rotSpeed = (Math.random() - 0.5) * 0.005; this.assetIndex = Math.floor(Math.random() * imageFiles.length); this.symbol = fallbackSymbols[this.assetIndex]; this.colorKey = assetColors[this.assetIndex]; this.opacity = 0; this.fadeSpeed = 0.005 + Math.random() * 0.005; this.isFadingIn = true; } update() { this.x += this.vx; this.y += this.vy; this.angle += this.rotSpeed; if (this.isFadingIn) { this.opacity += this.fadeSpeed; if (this.opacity >= 0.6) { this.opacity = 0.6; this.isFadingIn = false; } } else if (this.y < -100 || this.x < -100 || this.x > w + 100) { this.reset(); } else if (this.y < h * 0.2) { this.opacity -= this.fadeSpeed; if (this.opacity <= 0) this.reset(); } } draw(ctx) { ctx.save(); ctx.translate(this.x, this.y); ctx.rotate(this.angle); if (imagesReady && loadedImages[this.assetIndex]) { ctx.save(); ctx.strokeStyle = colors[this.colorKey] + (this.opacity * 0.5) + ")"; ctx.lineWidth = 10; drawRoundedRect(ctx, -this.w/2 - 2, -this.h/2 - 2, this.w + 4, this.h + 4, 8); ctx.stroke(); ctx.restore(); ctx.save(); ctx.globalAlpha = this.opacity * 1.5; const imgEl = loadedImages[this.assetIndex]; ctx.drawImage(imgEl, -this.w/2, -this.h/2, this.w, this.h); ctx.restore(); } else { ctx.fillStyle = "rgba(11, 14, 28, 0.75)"; drawRoundedRect(ctx, -this.w/2, -this.h/2, this.w, this.h, 8); ctx.fill(); ctx.strokeStyle = colors[this.colorKey] + (this.opacity * 0.3) + ")"; ctx.lineWidth = 6; ctx.stroke(); ctx.strokeStyle = colors[this.colorKey] + (this.opacity * 0.75) + ")"; ctx.lineWidth = 3; ctx.stroke(); ctx.strokeStyle = colors[this.colorKey] + (this.opacity * 1.0) + ")"; ctx.lineWidth = 1.5; ctx.stroke(); ctx.fillStyle = "rgba(255, 255, 255, " + (this.opacity * 1.8) + ")"; ctx.font = "bold 22px system-ui, -apple-system, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.shadowColor = "rgba(255, 255, 255, 0.5)"; ctx.shadowBlur = 4; ctx.fillText(this.symbol, 0, 0); } ctx.restore(); } } let cards = []; for (let i = 0; i < 21; i++) cards.push(new FloatingCard()); function animate() { ctx.clearRect(0, 0, w, h); cards.forEach(card => { card.update(); card.draw(ctx); }); animationId = requestAnimationFrame(animate); } animate(); } initAnimation(); const savedLangRaw = localStorage.getItem('uno_lang') || 'EN-US'; const savedLang = (savedLangRaw.includes('Portugu') || savedLangRaw === 'PT-BR') ? 'PT-BR' : 'EN-US'; return [localStorage.getItem('uno_name') || '', savedLang, h]; } """ game_theme = gr.themes.Default( primary_hue="blue", secondary_hue="yellow", neutral_hue="neutral" ).set( body_background_fill="*neutral_900", body_background_fill_dark="*neutral_900", background_fill_secondary="*neutral_900", background_fill_secondary_dark="*neutral_900", body_text_color="*neutral_200", body_text_color_dark="*neutral_200", block_background_fill="*neutral_800", block_background_fill_dark="*neutral_800", checkbox_label_background_fill="neutral_800", checkbox_label_background_fill_dark="neutral_800", checkbox_label_background_fill_selected="neutral_800", checkbox_label_background_fill_selected_dark="*neutral_800", body_text_size="1.1em", code_background_fill="*neutral_800", code_background_fill_dark="*neutral_800", shadow_drop="2px 2px 4px rgba(0, 0, 0, 0.5)", block_label_background_fill="*neutral_800", block_label_background_fill_dark="*neutral_800", block_label_text_color="*neutral_200", block_label_text_color_dark="*neutral_200", block_title_text_color="*primary_300", block_title_text_color_dark="*primary_300", panel_background_fill="*neutral_950", panel_background_fill_dark="*neutral_950", panel_border_color="*neutral_800", panel_border_color_dark="*neutral_800", checkbox_border_color="*neutral_700", checkbox_border_color_dark="*neutral_700", input_background_fill="*neutral_850", input_background_fill_dark="*neutral_850", input_border_color="*neutral_700", input_border_color_dark="*neutral_700", slider_color="*primary_400", slider_color_dark="*primary_400", button_primary_background_fill="*primary_600", button_primary_background_fill_dark="*primary_600", button_primary_background_fill_hover="*primary_700", button_primary_background_fill_hover_dark="*primary_700", button_primary_text_color="white", button_primary_text_color_dark="white", button_secondary_background_fill="*secondary_500", button_secondary_background_fill_dark="*secondary_500", button_secondary_background_fill_hover="*secondary_600", button_secondary_background_fill_hover_dark="*secondary_600", button_secondary_text_color="*neutral_950", button_secondary_text_color_dark="*neutral_950", button_cancel_background_fill="*neutral_800", button_cancel_background_fill_dark="*neutral_800", button_cancel_background_fill_hover="*neutral_700", button_cancel_background_fill_hover_dark="*neutral_700", button_cancel_text_color="*neutral_200", button_cancel_text_color_dark="*neutral_200", ) def play_card(data: dict[str, Any] | None = None) -> ServerResponse: """Server bridge for card-play events emitted by the custom HTML board. Args: data: Event payload containing player index, card index, and caller id. """ payload = data or {} return global_server.play_card(payload["player"], payload["card"], payload["caller"]) def draw_card(data: dict[str, Any] | None = None) -> ServerResponse: """Server bridge for draw-pile clicks emitted by the custom HTML board. Args: data: Event payload containing the caller id. """ payload = data or {} return global_server.draw_card(payload["caller"]) def select_wild_color(data: dict[str, Any] | None = None) -> ServerResponse: """Server bridge for wild-color selections emitted by the custom HTML board. Args: data: Event payload containing selected color and caller id. """ payload = data or {} return global_server.select_wild_color(payload["color"], payload["caller"]) def accuse_player(data: dict[str, Any] | None = None) -> ServerResponse: """Server bridge for accusation clicks emitted by the custom HTML board. Args: data: Event payload containing target index and caller id. """ payload = data or {} return global_server.accuse_player(payload["target"], payload["caller"]) def pass_turn_manual(data: dict[str, Any] | None = None) -> ServerResponse: """Server bridge for pass-turn clicks emitted by the custom HTML board. Args: data: Event payload containing the caller id. """ payload = data or {} return global_server.pass_turn_manual(payload["caller"]) def shout_deploy(data: dict[str, Any] | None = None) -> ServerResponse: """Server bridge for Deploy shout clicks emitted by the custom HTML board. Args: data: Event payload containing the caller id. """ payload = data or {} return global_server.shout_deploy(payload["caller"]) def leave_game(data: dict[str, Any] | None = None) -> ServerResponse: """Server bridge for leave-room clicks emitted by the custom HTML board. Args: data: Event payload containing the caller id. """ payload = data or {} return global_server.leave_game(payload["caller"]) def receive_toast(evt: gr.EventData) -> str: """Extract toast text from a custom HTML event. Args: evt: Gradio event data emitted by `trigger('show_toast', ...)`. """ return evt._data["msg"] def do_tick() -> None: """Advance the backend game clock.""" global_server.tick_countdown() def fetch_state_for_player(uid: str) -> tuple[Any, Any]: """Fetch a personalized game state payload for a logged-in player. Args: uid: Player name stored in Gradio state. """ if not uid or uid.strip() == "": return gr.update(), gr.update(visible=False) state = global_server.get_state(uid) state["viewer_id"] = uid return state, gr.update(visible=uid in global_server.queue) def fetch_state_for_spectator() -> GameState: """Fetch the public spectator state payload.""" state = global_server.get_state("") state["viewer_id"] = "" return state def get_lang_code(lang_choice: str) -> str: """Return the internal language code for a lobby language label. Args: lang_choice: Language label selected in the lobby. Returns: `pt` for Portuguese labels, otherwise `en`. """ normalized_choice = (lang_choice or "").upper() return "pt" if "PORTUG" in normalized_choice or normalized_choice == "PT-BR" else "en" def get_hf_userinfo(request: gr.Request | None) -> dict[str, Any]: """Read Hugging Face OAuth user info from a Gradio request. Args: request: Gradio request object injected into event handlers. Returns: OAuth user info dictionary, or an empty dictionary when unavailable. """ if request is None: return {} try: raw_request = getattr(request, "request", None) session = getattr(request, "session", None) or getattr(raw_request, "session", None) if not session: return {} oauth_info = session.get("oauth_info", {}) userinfo = oauth_info.get("userinfo", {}) if isinstance(userinfo, Mapping): return dict(userinfo) if hasattr(userinfo, "model_dump"): return dict(userinfo.model_dump()) if hasattr(userinfo, "dict"): return dict(userinfo.dict()) if hasattr(userinfo, "items"): return dict(userinfo.items()) return {} except Exception: return {} def get_zero_gpu_ip_token(request: gr.Request | None) -> str: """Read the Hugging Face ZeroGPU IP token from the incoming Gradio request.""" if request is None: return "" try: headers = getattr(request, "headers", None) raw_request = getattr(request, "request", None) if headers is None and raw_request is not None: headers = getattr(raw_request, "headers", None) if not headers: return "" token = headers.get("x-ip-token") or headers.get("X-IP-Token") or "" return str(token).strip() except Exception: return "" def get_hf_username(request: gr.Request | None) -> str: """Read the Hugging Face OAuth username from a Gradio request. Args: request: Gradio request object injected into event handlers. Returns: Authenticated Hugging Face username, or an empty string when unavailable. """ try: userinfo = get_hf_userinfo(request) username = userinfo.get("preferred_username") or userinfo.get("name") or "" return str(username).strip() except Exception: return "" def get_hf_picture_url(request: gr.Request | None) -> str: """Read the Hugging Face OAuth profile image URL from a Gradio request.""" try: picture_url = get_hf_userinfo(request).get("picture", "") return str(picture_url).strip() except Exception: return "" def resolve_hf_identity(request: gr.Request | None = None, *known_values: str) -> str: """Resolve the Hugging Face username from request, state, or hidden component values. Args: request: Gradio request object injected into event handlers. known_values: Previously stored Hugging Face usernames. Returns: First non-empty Hugging Face username found. """ request_username = get_hf_username(request) if request_username: return request_username for value in known_values: username = (value or "").strip() if username: return username return "" def get_hf_login_button_update(lang_code: str, hf_username: str = "") -> Any: """Return localized labels for the Hugging Face login button. Args: lang_code: Internal app language code. hf_username: Authenticated username, when available. Returns: Gradio update for the login button labels. """ t = APP_UI[lang_code] if hf_username: return gr.update(value=t["hf_logout_button"].format(hf_username), logout_value=t["hf_logout_button"]) return gr.update(value=t["hf_login_button"], logout_value=t["hf_logout_button"]) def format_lobby_player_status(player_name: str, lang_code: str) -> str: """Return the lobby status shown for a player waiting in the active room.""" t = APP_UI.get(lang_code, APP_UI["en"]) message = t["welcome_play"].replace("{name}", player_name) return f'{message}' def fetch_leaderboard_for_player(uid: str, lang_choice: str) -> str: """Render leaderboard HTML using the player or lobby language preference. Args: uid: Player name used to resolve language preference. lang_choice: Current language radio label used before a player joins. """ fallback_lang = get_lang_code(lang_choice) lang = global_server.player_langs.get(uid, fallback_lang) return global_server.render_leaderboard_html(lang) def execute_leave_ui(hf_uid: str = "", uid: str = "", request: gr.Request | None = None) -> tuple[str, str, Any, Any, Any, Any, Any, Any, str]: """Reset visible Gradio tabs after the custom board forces a leave action.""" hf_identity = resolve_hf_identity(request, hf_uid) name_update = gr.update(value=hf_identity, visible=False) if hf_identity else gr.update(visible=True) return "", "", gr.update(visible=False), gr.update(), gr.update(selected="tab_lobby"), gr.update(visible=False), gr.update(interactive=True), name_update, hf_identity def leave_queue_from_lobby(uid: str, lang_choice: str) -> tuple[Any, ...]: """Remove a queued user from the lobby without entering the player board. Args: uid: Player name stored in Gradio state. lang_choice: Current language radio label. Returns: Gradio output tuple that resets the lobby to the anonymous state. """ fallback_lang = get_lang_code(lang_choice) t = APP_UI[fallback_lang] if uid in global_server.players: msg = t["welcome_play"].replace("{name}", uid) selected_tab = "tab_player" if global_server.game_started else "tab_lobby" return uid, msg, gr.update(), gr.update(visible=True), gr.update(selected=selected_tab), gr.update(), gr.update(visible=False), gr.update(interactive=False) if not uid or uid not in global_server.queue: return "", t["status"], gr.update(), gr.update(visible=False), gr.update(selected="tab_lobby"), gr.update(), gr.update(visible=False), gr.update(interactive=True) result = global_server.leave_game(uid) state = result.get("state") or global_server.get_state("") state["viewer_id"] = "" return "", result.get("toast", t["status"]), gr.update(value=state), gr.update(visible=False), gr.update(selected="tab_lobby"), gr.update(), gr.update(visible=False), gr.update(interactive=True) def change_lang_ui(choice: str, uid: str, hf_uid: str = "", request: gr.Request | None = None) -> tuple[Any, ...]: """Update lobby labels and leaderboard HTML after a language change. Args: choice: Label from the language radio component. uid: Player name stored in Gradio state. hf_uid: Hugging Face username stored from the login status refresh. request: Gradio request used to read an optional Hugging Face OAuth username. """ lang = get_lang_code(choice) t = APP_UI[lang] is_registered = bool(uid and (uid in global_server.players or uid in global_server.queue)) is_queued = bool(uid and uid in global_server.queue) if uid and is_registered: global_server.player_langs[uid] = lang global_server.set_player_ip_token(uid, get_zero_gpu_ip_token(request)) if BOT_NAME in global_server.players and uid in global_server.players: global_server.player_langs[BOT_NAME] = lang hf_username = resolve_hf_identity(request, hf_uid) if hf_username: HF_AUTH_PLAYER_NAMES.add(hf_username) hf_status = t["hf_login_authenticated"].replace("{name}", hf_username) if hf_username else t["hf_login_guest"] name_update = gr.update(label=t["name_label"], value=hf_username, visible=False) if hf_username else gr.update(label=t["name_label"], visible=True) styled_title = f'
{t["subtitle"]}
' return ( styled_title, styled_sub, gr.update(label=t["lang_label"]), name_update, gr.update(value=t["btn_join"], interactive=not is_registered), gr.update(value=t["btn_leave_queue"], visible=is_queued), t["status"], gr.update(label=t["tab_lobby"]), gr.update(label=t["tab_player"]), gr.update(label=t["tab_leaderboard"]), global_server.render_leaderboard_html(lang), gr.update(label=t["tab_manual"]), render_how_to_play_html(lang), hf_status, get_hf_login_button_update(lang, hf_username), ) def join_match(player_name: str, lang_choice: str, current_uid: str = "", hf_uid: str = "", request: gr.Request | None = None) -> tuple[Any, ...]: """Join a player to the match or queue from the lobby form. Args: player_name: Name typed by the user. lang_choice: UI language radio label. current_uid: Existing player name already assigned to this browser tab. hf_uid: Hugging Face username stored from the login status refresh. request: Gradio request used to read an optional Hugging Face OAuth username. Returns: Gradio output tuple for user id, status, board state, tab visibility, tab selection, and lobby visibility. """ lang_code = get_lang_code(lang_choice) hf_username = resolve_hf_identity(request, hf_uid) if hf_username: HF_AUTH_PLAYER_NAMES.add(hf_username) name = hf_username or (player_name or "").strip() known_hf_username = hf_username hf_state = known_hf_username or hf_uid name_update = gr.update(value=known_hf_username, visible=False) if known_hf_username else gr.update(value=name, visible=True) t = APP_UI[lang_code] if not name: return "", t["invalid_name"], gr.update(), gr.update(), gr.update(), gr.update(), gr.update(visible=False), gr.update(interactive=True), gr.update(visible=True), hf_state current_uid = (current_uid or "").strip() if current_uid in global_server.players or current_uid in global_server.queue: res_name = current_uid global_server.player_langs[res_name] = lang_code else: res_name = global_server.join_lobby(name, lang_code) if res_name == "DUPLICATE_REJECT": return "", t["duplicate_name"], gr.update(), gr.update(), gr.update(), gr.update(), gr.update(visible=False), gr.update(interactive=True), name_update, hf_state global_server.set_player_ip_token(res_name, get_zero_gpu_ip_token(request)) is_hf_identity = bool(known_hf_username and res_name == known_hf_username) global_server.set_player_authenticated(res_name, is_hf_identity) if hf_username and res_name == hf_username: global_server.set_player_picture(res_name, get_hf_picture_url(request)) new_state = global_server.get_state(res_name) new_state["viewer_id"] = res_name is_active_room_player = res_name in global_server.players if is_active_room_player and global_server.can_start_lobby_match(): if not global_server.modal_is_warm and not global_server.modal_is_warming_up: # Thread-Lock: Set warming up immediately on the main thread global_server.modal_is_warming_up = True threading.Thread(target=async_modal_warmup, daemon=True).start() # Keep players in lobby with a beautiful progress warning instead of redirecting them immediately msg = f'
')
title_html = gr.HTML(f'{initial_ui["subtitle"]}
') hf_login_btn = gr.LoginButton(value=initial_ui["hf_login_button"], logout_value=initial_ui["hf_logout_button"]) auth_status = gr.Markdown(initial_ui["hf_login_guest"]) name_input = gr.Textbox(label=initial_ui["name_label"], elem_id="manual_name_input") join_btn = gr.Button(initial_ui["btn_join"], variant="primary") status_msg = gr.Markdown(initial_ui["status"]) leave_queue_btn = gr.Button(initial_ui["btn_leave_queue"], visible=False, elem_id="leave_queue_btn") init_state = global_server.get_state("") init_state["viewer_id"] = "" spectator_board = Board(value=init_state, server_functions=BOARD_SERVER_FUNCTIONS) with gr.Tab(initial_ui["tab_player"], id="tab_player", visible=False) as player_tab: player_board = Board(value=init_state, server_functions=BOARD_SERVER_FUNCTIONS) with gr.Tab(initial_ui["tab_leaderboard"], id="tab_leaderboard") as leaderboard_tab: leaderboard_board = gr.HTML(value=global_server.render_leaderboard_html("en")) with gr.Tab(initial_ui["tab_manual"], id="tab_manual") as manual_tab: manual_board = gr.HTML(value=render_how_to_play_html("en")) lang_input.change( fn=change_lang_ui, inputs=[lang_input, user_id, hf_user_id], outputs=[title_html, sub_html, lang_input, name_input, join_btn, leave_queue_btn, status_msg, lobby_tab, player_tab, leaderboard_tab, leaderboard_board, manual_tab, manual_board, auth_status, hf_login_btn], show_progress=False, ) join_btn.click( fn=join_match, inputs=[name_input, lang_input, user_id, hf_user_id], outputs=[user_id, status_msg, player_board, player_tab, main_tabs, login_box, leave_queue_btn, join_btn, name_input, hf_user_id], js="(n, l, u, h) => { localStorage.setItem('uno_name', u || h || n); localStorage.setItem('uno_lang', l); return [n, l, u, h]; }", show_progress=False, ) leave_queue_btn.click( fn=leave_queue_from_lobby, inputs=[user_id, lang_input], outputs=[user_id, status_msg, player_board, player_tab, main_tabs, login_box, leave_queue_btn, join_btn], js="(u, l) => { localStorage.removeItem('uno_name'); localStorage.removeItem('uno_lang'); return [u, l]; }", show_progress=False, ) demo.load( fn=check_auto_login, inputs=[name_input, lang_input, hf_user_id], outputs=[user_id, status_msg, player_board, player_tab, main_tabs, login_box, leave_queue_btn, join_btn, name_input, hf_user_id, auth_status, hf_login_btn, lang_input], js=GLOBAL_JS, show_progress=False, ) player_board.show_toast(fn=receive_toast, inputs=None, outputs=toast_ui, show_progress=False) spectator_board.show_toast(fn=receive_toast, inputs=None, outputs=toast_ui, show_progress=False) player_board.force_leave_ui(fn=execute_leave_ui, inputs=[hf_user_id, user_id], outputs=[user_id, status_msg, player_tab, login_box, main_tabs, leave_queue_btn, join_btn, name_input, hf_user_id], show_progress=False) tick_timer = gr.Timer(TICK_RATE_SERVER_SECONDS) tick_timer.tick(fn=do_tick, inputs=[], outputs=[], show_progress=False) player_sync_timer = gr.Timer(SYNC_RATE_PLAYER_SECONDS) player_sync_timer.tick(fn=fetch_state_for_player, inputs=[user_id], outputs=[player_board, leave_queue_btn], show_progress=False) spectator_sync_timer = gr.Timer(SYNC_RATE_SPECTATOR_SECONDS) spectator_sync_timer.tick(fn=fetch_state_for_spectator, inputs=[], outputs=[spectator_board], show_progress=False) leaderboard_timer = gr.Timer(SYNC_RATE_LEADERBOARD_SECONDS) leaderboard_timer.tick(fn=fetch_leaderboard_for_player, inputs=[user_id, lang_input], outputs=[leaderboard_board], show_progress=False) lobby_timer = gr.Timer(TICK_LOBBY_WARMUP_SECONDS) lobby_timer.tick( fn=lobby_sync_check, inputs=[user_id, lang_input], outputs=[player_tab, main_tabs, login_box, leave_queue_btn, join_btn, player_board, status_msg], show_progress=False, ) threading.Thread(target=llm_queue_worker, daemon=True).start() threading.Thread(target=tts_audio_queue_worker, daemon=True).start() os.makedirs("assets", exist_ok=True) demo.launch(allowed_paths=["./assets"], server_name="0.0.0.0", css=GLOBAL_CSS, theme=game_theme)