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 = """
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.