"""Quest generator: input handling, generation, rendering, export.""" import html import random import tempfile from pathlib import Path from .shared import ai_client from .shared.ratelimit import RateLimiter from .prompts import SCHEMA_SPEC, SYSTEM_PROMPT, SCOPE_GUIDE, build_user_prompt from .fixtures import FIXTURE_GULLWRACK limiter = RateLimiter() QUEST_TYPES = [ "Rescue", "Retrieval", "Mystery", "Heist", "Escort", "Defense", "Exploration", "Faction Politics", "Monster Hunt", "Delivery Gone Wrong", ] LEVELS = [str(i) for i in range(1, 21)] SIZES = [str(i) for i in range(2, 8)] TONES = [ "Heroic", "Grim", "Whimsical", "Mysterious", "Morally gray", "Horror-tinged", ] SCOPES = list(SCOPE_GUIDE.keys()) # One-shot, Short arc (2-3 sessions), Campaign thread MAX_FIELD = 300 MAX_NOTES = 800 def randomize(): """Fill the core inputs with a random runnable combination.""" return ( random.choice(QUEST_TYPES), random.choice(LEVELS), random.choice(TONES), random.choice(SCOPES), ) def _clip(value: str, limit: int) -> str: return (value or "").strip()[:limit] def generate( quest_type, party_level, party_size, setting, tone, quest_giver, pc_tie, scope, notes, request=None, ): """Returns (markdown, plain_text, download_path, error_message).""" allowed, message = limiter.check(request) if not allowed: return None, None, None, message scope = scope if scope in SCOPE_GUIDE else "One-shot" fields = { "Quest type": _clip(quest_type, MAX_FIELD), "Party level": _clip(str(party_level or ""), 10), "Party size (players)": _clip(str(party_size or ""), 10), "Setting / region": _clip(setting, MAX_FIELD), "Tone": _clip(tone, 60), "Quest giver idea": _clip(quest_giver, MAX_FIELD), "Tie-in to a player character": _clip(pc_tie, MAX_FIELD), "Scope": scope, "DM notes": _clip(notes, MAX_NOTES), } user_prompt = build_user_prompt(fields) + "\n\n" + SCOPE_GUIDE[scope] try: data = ai_client.generate_json( SYSTEM_PROMPT, user_prompt, SCHEMA_SPEC, fixture=FIXTURE_GULLWRACK, ) except ai_client.AIUnavailable as exc: return None, None, None, str(exc) md = render_markdown(data) path = Path(tempfile.mkdtemp()) / "dnd-quest.md" path.write_text(md, encoding="utf-8") return md, md, str(path), None def render_markdown(d: dict) -> str: complications = "\n".join( f"{i}. {c}" for i, c in enumerate(d["complications"], 1) ) followups = "\n".join( f"{i}. {h}" for i, h in enumerate(d["followup_hooks"], 1) ) return f"""## {d['title']} ### The Hook {d['hook']} ### Quest Giver {d['quest_giver']} ### Objective {d['objective']} ### Location {d['location']} ### Three Complications {complications} ### The Twist {d['twist']} ### Antagonist {d['antagonist']} ### Reward {d['reward']} ### Where It Leads {followups} ### DM Summary {d['dm_summary']} """ def render_error(message: str) -> str: return ( '
{html.escape(message)}