"""Monster builder: 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, build_user_prompt from .fixtures import FIXTURE_DROWNLIGHT_SCUTTLER limiter = RateLimiter() CHALLENGE_RATINGS = ["1/8", "1/4", "1/2"] + [str(n) for n in range(1, 21)] SIZES = ["Tiny", "Small", "Medium", "Large", "Huge", "Gargantuan"] CREATURE_TYPES = [ "Aberration", "Beast", "Celestial", "Construct", "Dragon", "Elemental", "Fey", "Fiend", "Giant", "Humanoid", "Monstrosity", "Ooze", "Plant", "Undead", ] COMBAT_ROLES = [ "Brute", "Skirmisher", "Artillery", "Controller", "Lurker", "Leader", "Minion swarm", ] MAX_FIELD = 300 MAX_NOTES = 800 def randomize(): """Fill the core dropdowns with a random buildable combination.""" return ( random.choice(CHALLENGE_RATINGS), random.choice(SIZES), random.choice(CREATURE_TYPES), random.choice(COMBAT_ROLES), ) def _clip(value: str, limit: int) -> str: return (value or "").strip()[:limit] def generate(concept, cr, size, creature_type, role, environment, notes, request=None): """Returns (markdown, plain_text, download_path, error_message).""" allowed, message = limiter.check(request) if not allowed: return None, None, None, message fields = { "Monster concept": _clip(concept, MAX_FIELD), "Challenge rating target": _clip(cr, 20), "Size": _clip(size, 40), "Creature type": _clip(creature_type, MAX_FIELD), "Combat role": _clip(role, MAX_FIELD), "Environment": _clip(environment, MAX_FIELD), "Extra notes": _clip(notes, MAX_NOTES), } user_prompt = build_user_prompt(fields) try: data = ai_client.generate_json( SYSTEM_PROMPT, user_prompt, SCHEMA_SPEC, fixture=FIXTURE_DROWNLIGHT_SCUTTLER, ) except ai_client.AIUnavailable as exc: return None, None, None, str(exc) md = render_markdown(data) path = Path(tempfile.mkdtemp()) / "dnd-monster.md" path.write_text(md, encoding="utf-8") return md, md, str(path), None def render_markdown(d: dict) -> str: traits = "\n".join(f"- {t}" for t in d["traits"]) actions = "\n".join(f"- {a}" for a in d["actions"]) return f"""## {d['name']} *{d['size_type_alignment']}* {d['lore']} **Armor Class** {d['armor_class']} \n**Hit Points** {d['hit_points']} \n**Speed** {d['speed']} \n**Ability Scores** {d['ability_scores']} \n**Saves & Skills** {d['saves_and_skills']} \n**Defenses** {d['defenses']} \n**Senses & Languages** {d['senses_and_languages']} \n**Challenge** {d['challenge']} ### Traits {traits} ### Actions {actions} ### Tactics {d['tactics']} ### Loot {d['loot']} """ def render_error(message: str) -> str: return ( '
{html.escape(message)}