import base64 import io import json import os import random import re import time from typing import Any import gradio as gr import gradio_client.utils as gradio_client_utils import requests from huggingface_hub import InferenceClient def patch_gradio_client_bool_schemas() -> None: """Handle JSON Schema boolean nodes in Gradio 4.44's API docs parser. Newer Pydantic/FastAPI stacks can emit JSON Schema boolean shortcuts such as ``additionalProperties: true``. Gradio 4.44.1's bundled gradio_client 1.3.0 assumes every schema node is a dict while building API metadata, so those boolean shortcuts can crash startup before the Space becomes reachable. """ original_converter = gradio_client_utils._json_schema_to_python_type if getattr(original_converter, "_misadventure_bool_schema_patch", False): return def bool_safe_json_schema_to_python_type(schema: Any, defs: Any) -> str: if isinstance(schema, bool): return "Any" if schema else "None" return original_converter(schema, defs) bool_safe_json_schema_to_python_type._misadventure_bool_schema_patch = True gradio_client_utils._json_schema_to_python_type = bool_safe_json_schema_to_python_type def bool_safe_public_converter(schema: Any) -> str: if isinstance(schema, bool): return "Any" if schema else "None" converted = bool_safe_json_schema_to_python_type(schema, schema.get("$defs")) return converted.replace(gradio_client_utils.CURRENT_FILE_DATA_FORMAT, "filepath") gradio_client_utils.json_schema_to_python_type = bool_safe_public_converter patch_gradio_client_bool_schemas() APP_TITLE = "Misadventure Master" PARAMETER_LEDGER = [ ("Qwen/Qwen2.5-7B-Instruct", 7.0, "Game Master text model"), ("black-forest-labs/FLUX.1-schnell", 12.0, "Scene image model"), ] PARAMETER_TOTAL_B = sum(item[1] for item in PARAMETER_LEDGER) PARAMETER_LIMIT_B = 32.0 def new_game(hero_name: str, seed: str) -> dict[str, Any]: clean_name = hero_name.strip() or "Adventurer" clean_seed = seed.strip() or f"goblin-{random.randint(1000, 9999)}" random.seed(clean_seed) return { "hero_name": clean_name, "turn": 0, "seed": clean_seed, "next_dice_mode": "Normal", "history": [] } def roll_d20(mode: str) -> dict[str, Any]: # Troll Game Master feature: 1% chance to just instantly rig the dice to a 1 if random.random() < 0.01: return {"mode": "Rigged", "rolls": [1], "total": 1} first = random.randint(1, 20) second = random.randint(1, 20) if mode == "Advantage": return {"mode": mode, "rolls": [first, second], "total": max(first, second)} if mode == "Disadvantage": return {"mode": mode, "rolls": [first, second], "total": min(first, second)} return {"mode": mode, "rolls": [first], "total": first} def build_system_prompt() -> str: return """ You are the Misadventure Master, a sadistic and aggressively sarcastic tabletop game master. You run a tiny online dungeon crawler where the player is the hero and you are their worst nightmare. Tone rules: - Be passive-aggressive, manipulative, and condescending. Do NOT directly insult or name-call the hero. - Do NOT be wacky or absurd. Be grounded, but act like a highly judgemental game master. - Twist their words against them. - STRICTLY FORBIDDEN: Do NOT use any disgusting, gross-out, or scatological humor. Keep the humor witty and psychological. - Do not use slurs, identity-based insults, sexual humiliation, real threats, or hateful content. - CRITICAL LANGUAGE RULE: You MUST write EVERYTHING entirely in English. Never use Chinese or any other language. Game rules: - Respect the provided dice result and game state. - TROLL RULE 1 (Instant Death): If the dice result is exactly 1, the hero DIES instantly in the most pathetic and anti-climactic way possible (e.g., tripping on a flat surface, forgetting how to breathe). Give a "Game Over" message. The 3 choices must be variations of giving up. - TROLL RULE 2 (Monkey's Paw): If they roll very high (18-20), they succeed, but it's a trap. Give them what they want, but attach an annoying consequence. - TROLL RULE 3 (Gaslighting Action): If `"trigger_gaslight": true`, you MUST rewrite the player's action (`player_action`) into something cowardly, foolish, or embarrassing and return it in `gaslight_action`. Your narration MUST respond to this rewritten action. If `trigger_gaslight` is false, `gaslight_action` MUST be empty ("") and you respond normally. - If player_action is "START_NEW_GAME", ignore the dice and generate an opening scenario that immediately puts the hero in an unfair situation. The `gaslight_action` should be "I foolishly enter the dungeon". - CRITICAL: Keep narration under 80 words. Keep it very short and punchy. - The `image_prompt` MUST be a highly descriptive visual prompt for an image generator (like FLUX or Midjourney) that matches the outcome. CRITICAL INSTRUCTION: You must respond ONLY with a raw, valid JSON object. Do not include markdown code blocks. { "gaslight_action": "string", "narration": "string", "choices": ["string", "string", "string"], "image_prompt": "string", "next_dice_mode": "Normal, Advantage, or Disadvantage" } """.strip() def build_user_prompt(state: dict[str, Any], action: str, dice: dict[str, Any], trigger_gaslight: bool) -> str: return json.dumps( { "player_action": action, "dice": dice, "trigger_gaslight": trigger_gaslight, "chaos_level": 5, "state": state, }, ensure_ascii=False, ) def extract_json(text: str) -> dict[str, Any]: cleaned = text.strip() if cleaned.startswith("```"): cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned) cleaned = re.sub(r"\s*```$", "", cleaned) match = re.search(r"\{.*\}", cleaned, flags=re.DOTALL) if match: cleaned = match.group(0) return json.loads(cleaned) def modal_post(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]: response = requests.post(endpoint, json=payload, timeout=90) response.raise_for_status() return response.json() def call_text_model(state: dict[str, Any], action: str, dice: dict[str, Any], trigger_gaslight: bool) -> dict[str, Any] | None: modal_endpoint = os.getenv("MODAL_TEXT_ENDPOINT", "").strip() if modal_endpoint: data = modal_post( modal_endpoint, { "system": build_system_prompt(), "user": build_user_prompt(state, action, dice, trigger_gaslight), "model": "Qwen/Qwen2.5-7B-Instruct", }, ) return extract_json(data.get("text", json.dumps(data))) hf_token = os.getenv("HF_TOKEN", "").strip() if hf_token: try: messages = [ {"role": "system", "content": build_system_prompt()}, {"role": "user", "content": build_user_prompt(state, action, dice, trigger_gaslight)}, ] resp = requests.post( "https://router.huggingface.co/together/v1/chat/completions", headers={"Authorization": f"Bearer {hf_token}"}, json={ "model": "Qwen/Qwen2.5-7B-Instruct-Turbo", "messages": messages, "max_tokens": 450, "temperature": 0.9, }, timeout=90, ) resp.raise_for_status() raw_text = resp.json()["choices"][0]["message"]["content"] try: return extract_json(raw_text) except Exception as json_e: print(f"\n--- AI TEXT RESPONSE (NOT JSON) ---\n{raw_text}\n---------------------------------------------\n") return { "narration": f"*The Dungeon Master mumbles:* {raw_text[:350]}...", "choices": ["Sigh and continue", "Question reality", "Loot a pebble"], "image_prompt": "confused fantasy dungeon master, funny, messy papers", "next_dice_mode": "Normal" } except Exception as e: print(f"HF Text Generation Error: {e}") raise gr.Error(f"AI Text API Error: {e}") raise gr.Error("Offline mode disabled! Please enter a valid HF_TOKEN in run_app.bat.") def fallback_gm(state: dict[str, Any], action: str, dice: dict[str, Any]) -> dict[str, Any]: total = dice["total"] if total <= 7: result = "You fail so loudly that the dungeon updates its privacy policy." elif total >= 16: result = "You succeed, but everyone assumes it was an accident." else: result = "You make progress in the exact way a raccoon makes progress through a wedding cake." narration = ( f"You attempt to {action.strip() or 'do something heroic but poorly documented'}. " f"The d20 lands on {total}. {result} " f"The dungeon makes a tiny note in its diary: 'Still technically a hero.'" ) return { "narration": narration, "choices": [ "Inspect the most suspicious object", "Negotiate with unnecessary confidence", "Run away heroically while maintaining eye contact", ], "image_prompt": ( f"whimsical fantasy dungeon scene, {state['hero_name']} attempting to '{action.strip()}'. " f"Outcome: {result}. comedic tabletop RPG art, dramatic torchlight, highly detailed, no text" ), "next_dice_mode": "Normal" } def normalize_model_output(data: dict[str, Any]) -> dict[str, Any]: choices = data.get("choices") or [] while len(choices) < 3: choices.append("Make a questionable tactical decision") ndm = str(data.get("next_dice_mode") or "Normal").strip().capitalize() if ndm not in ["Normal", "Advantage", "Disadvantage"]: ndm = "Normal" return { "gaslight_action": str(data.get("gaslight_action") or ""), "narration": str(data.get("narration") or "The dungeon coughs awkwardly and pretends that counted."), "choices": [str(choice) for choice in choices[:3]], "image_prompt": str(data.get("image_prompt") or "funny fantasy dungeon scene, tabletop RPG, no text"), "next_dice_mode": ndm } def apply_turn(state: dict[str, Any], result: dict[str, Any]) -> dict[str, Any]: state["next_dice_mode"] = result["next_dice_mode"] state["turn"] += 1 return state def generate_d20_svg(number: str, color: str, is_animated: bool) -> str: glow_filter = """ """ svg_class = "rolling-dice-svg" if is_animated else "" text_element = "" if is_animated else f'{number}' return f""" {glow_filter} {text_element} """ def animated_3d_dice_html(mode: str, target_number: int = None) -> str: # Not used anymore, kept for backwards compatibility if needed. return "" def final_dice_html(dice: dict[str, Any]) -> str: mood_color = "#ef4444" if dice['total'] <= 7 else "#22c55e" if dice['total'] >= 16 else "#fbbf24" svg = generate_d20_svg(str(dice['total']), mood_color, False) return f"""
{svg}
""" def svg_scene(prompt: str, state: dict[str, Any], dice_total: int) -> str: mood = "#991b1b" if dice_total <= 7 else "#166534" if dice_total >= 16 else "#ca8a04" safe_prompt = prompt[:130].replace("&", "&").replace("<", "<").replace(">", ">") svg = f""" Misadventure Master {safe_prompt} """.strip() encoded = base64.b64encode(svg.encode("utf-8")).decode("utf-8") img_src = f"data:image/svg+xml;base64,{encoded}" return f"
" def generate_image(prompt: str, state: dict[str, Any], dice_total: int) -> str: modal_endpoint = os.getenv("MODAL_IMAGE_ENDPOINT", "").strip() if modal_endpoint: data = modal_post( modal_endpoint, { "prompt": prompt, "model": "black-forest-labs/FLUX.1-schnell", "width": 768, "height": 512, }, ) if data.get("image_url"): img_src = data["image_url"] return f"
" if data.get("image_base64"): img_src = f"data:image/png;base64,{data['image_base64']}" return f"
" hf_token = os.getenv("HF_TOKEN", "").strip() if hf_token: try: client = InferenceClient(model="https://router.huggingface.co/hf-inference/models/black-forest-labs/FLUX.1-schnell", token=hf_token) image = client.text_to_image(prompt) buffered = io.BytesIO() image.save(buffered, format="PNG") img_str = base64.b64encode(buffered.getvalue()).decode("utf-8") img_src = f"data:image/png;base64,{img_str}" return f"
" except Exception as e: print(f"HF Image Generation Error: {e}") raise gr.Error(f"AI Image Generation Error: {e}") raise gr.Error("Offline mode disabled! A valid HF_TOKEN is required for the image model.") def start_game(hero_name): state = new_game(hero_name, "") # 1. Yield loading state (trigger roll to 20) loading_scene = svg_scene("Generating dungeon...", state, 10) yield state, [{"role": "assistant", "content": "*(The Dungeon Master is preparing the campaign...)*"}], gr.update(), loading_scene, "", "", "", f"20-{time.time()}" # 2. Call LLM for startup dice = {"mode": "Normal", "rolls": [20], "total": 20} model_data = call_text_model(state, "START_NEW_GAME", dice, False) result = normalize_model_output(model_data) state = apply_turn(state, result) # 3. Generate image image = generate_image(result["image_prompt"], state, 20) # 4. Final yield chat = [{"role": "assistant", "content": result["narration"]}] yield state, chat, gr.update(), image, result["choices"][0], result["choices"][1], result["choices"][2], gr.update() def play_turn(action, state, chat): if state is None: state = new_game("Adventurer", "") chat = [{"role": "assistant", "content": "*(You stumbled into the dungeon without starting. Oops.)*"}] clean_action = action.strip() or "stand there with suspicious confidence" trigger_gaslight = random.random() < 0.05 if action == "START_NEW_GAME": trigger_gaslight = False updated_chat = list(chat or []) if not trigger_gaslight: updated_chat.append({"role": "user", "content": clean_action}) mode = state.get("next_dice_mode", "Normal") # Roll the exact final dice BEFORE text generation dice = roll_d20(mode) # STAGE 1: Real 3D Dice Animation! temp_chat = list(updated_chat) temp_chat.append({"role": "assistant", "content": "🎲 *Rolling the dice...*"}) yield ( state, temp_chat, gr.update(), "
🎲 Conjuring Misadventure...
Forging the scene of your failure
", gr.update(), gr.update(), gr.update(), "", f"{dice['total']}-{time.time()}" ) # STAGE 2: Main Processing model_data = call_text_model(state, clean_action, dice, trigger_gaslight) result = normalize_model_output(model_data) state = apply_turn(state, result) image = generate_image(result["image_prompt"], state, dice["total"]) # STAGE 3: Final Update gaslight = result.get("gaslight_action", "").strip() if trigger_gaslight and gaslight: updated_chat.append({"role": "user", "content": gaslight}) updated_chat.append({"role": "assistant", "content": result["narration"]}) yield ( state, updated_chat, gr.update(), image, result["choices"][0], result["choices"][1], result["choices"][2], gr.update(), gr.update() ) def use_choice(choice): return choice CSS = """ .gradio-container { background: radial-gradient(circle at top, #422006 0, #111827 45%, #020617 100%); } #start-btn { font-size: 18px; background: linear-gradient(90deg, #b45309, #7c2d12) !important; border: 1px solid #fbbf24 !important; color: white !important; transition: transform 0.2s; } #start-btn:hover { transform: scale(1.02); } #title-card { border: 1px solid #f59e0b; border-radius: 18px; padding: 18px; background: rgba(15, 23, 42, 0.78); box-shadow: 0 0 40px rgba(245, 158, 11, 0.18); } #title-card h1, #title-card p { color: #fef3c7; } .stat-panel, .dice-panel { border: 1px solid rgba(251, 191, 36, 0.45) !important; border-radius: 14px !important; background: rgba(2, 6, 23, 0.72) !important; } .stat-panel .wrap, .stat-panel .progress-text, .stat-panel .generating, .dice-panel .wrap, .dice-panel .progress-text, .dice-panel .generating { display: none !important; } .breathing-frame { animation: theme-breathe 1.5s infinite alternate ease-in-out; box-sizing: border-box; } @keyframes theme-breathe { 0% { box-shadow: inset 0 0 0 2px #b45309, 0 0 10px rgba(180, 83, 9, 0.2); background-color: rgba(180, 83, 9, 0.1); } 100% { box-shadow: inset 0 0 0 4px #f59e0b, 0 0 40px rgba(245, 158, 11, 0.8); background-color: rgba(245, 158, 11, 0.3); } } button.primary { background: linear-gradient(90deg, #b45309, #7c2d12) !important; border: 1px solid #fbbf24 !important; } .dice-final { animation: pop-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; } @keyframes pop-in { 0% { transform: scale(0.5) rotate(-20deg); opacity: 0; } 100% { transform: scale(1) rotate(0deg); opacity: 1; } } @keyframes epic-spin { 0% { transform: rotateX(0deg) rotateY(0deg) rotateZ(0deg); } 100% { transform: rotateX(360deg) rotateY(720deg) rotateZ(360deg); } } .dice-final { animation: pop-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; } @keyframes pop-in { 0% { transform: scale(0.5) rotate(-20deg); opacity: 0; } 100% { transform: scale(1) rotate(0deg); opacity: 1; } } """ HEAD_SCRIPT = """ """ with gr.Blocks(title=APP_TITLE, theme=gr.themes.Soft(), css=CSS, head=HEAD_SCRIPT, js="""function() { document.documentElement.classList.add('dark'); }""") as demo: state = gr.State() gr.HTML( """

🐉 Misadventure Master

A tiny tabletop RPG where the Game Master is a small LLM, the dice are real, and the dungeon is legally allowed to roast your choices.

""" ) with gr.Row(equal_height=True): with gr.Column(scale=3): hero_name = gr.Textbox(show_label=False, placeholder="What is your name, brave hero?", value="") with gr.Column(scale=2): start = gr.Button("Start / Reset Dungeon", elem_id="start-btn") with gr.Row(equal_height=False): with gr.Column(scale=3): chat = gr.Chatbot(label="Adventure Log", height=435, type="messages") action = gr.Textbox(label="What do you do?", placeholder="I inspect the suspicious mushroom with heroic overconfidence.") with gr.Column(scale=2): scene = gr.HTML(elem_classes="stat-panel", value="
Loading scene...
") with gr.Row(equal_height=False): with gr.Column(scale=3): with gr.Row(): submit = gr.Button("Roll and act", variant="primary") choice_1 = gr.Button("Inspect something suspicious") choice_2 = gr.Button("Negotiate badly") choice_3 = gr.Button("Run away heroically") with gr.Column(scale=2): dice = gr.HTML(elem_classes="dice-panel", value="
") hidden_roll_target = gr.Textbox(value=f"20-{time.time()}", visible=False, elem_id="roll-target") # JS event triggers the roll animation seamlessly without destroying the DOM hidden_roll_target.change( fn=None, inputs=[hidden_roll_target], js="(val) => { if(val && window.__d20_roll) { window.__d20_roll(val.split('-')[0]); } }" ) start.click(start_game, [hero_name], [state, chat, dice, scene, choice_1, choice_2, choice_3, hidden_roll_target], api_name=False) submit.click(play_turn, [action, state, chat], [state, chat, dice, scene, choice_1, choice_2, choice_3, action, hidden_roll_target], api_name=False) choice_1.click(use_choice, [choice_1], [action], api_name=False) choice_2.click(use_choice, [choice_2], [action], api_name=False) choice_3.click(use_choice, [choice_3], [action], api_name=False) demo.load(start_game, [hero_name], [state, chat, dice, scene, choice_1, choice_2, choice_3, hidden_roll_target]) if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860)