Spaces:
Sleeping
Sleeping
| """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 ( | |
| '<div class="lf-output" role="alert" style="border-color:#A7343B;">' | |
| f"<p>{html.escape(message)}</p></div>" | |
| ) | |