"""Statblock 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, build_user_prompt from .fixtures import FIXTURE_RUSTBOUND_SENTINEL 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", ] MAX_CONCEPT = 2000 MAX_FIELD = 300 MAX_EXTRA = 800 def randomize(): """Fill the framing dropdowns randomly; the concept stays the user's.""" return ( random.choice(CHALLENGE_RATINGS), random.choice(SIZES), random.choice(CREATURE_TYPES), ) def _clip(value: str, limit: int) -> str: return (value or "").strip()[:limit] def generate(concept, cr, size, creature_type, legendary, extra, request=None): """Returns (markdown, plain_text, download_path, error_message).""" allowed, message = limiter.check(request) if not allowed: return None, None, None, message fields = { "Creature concept / homebrew notes": _clip(concept, MAX_CONCEPT), "Target challenge rating": _clip(cr, 20), "Size": _clip(size, 40), "Creature type": _clip(creature_type, MAX_FIELD), "Include legendary actions": "yes" if legendary else "no", "Extra mechanical requests": _clip(extra, MAX_EXTRA), } user_prompt = build_user_prompt(fields) try: data = ai_client.generate_json( SYSTEM_PROMPT, user_prompt, SCHEMA_SPEC, fixture=FIXTURE_RUSTBOUND_SENTINEL, ) except ai_client.AIUnavailable as exc: return None, None, None, str(exc) md = render_markdown(data) path = Path(tempfile.mkdtemp()) / "dnd-statblock.md" path.write_text(md, encoding="utf-8") return md, md, str(path), None def _has_content(value) -> bool: """True unless the field is empty or the literal 'None' sentinel.""" return (value or "").strip().rstrip(".").lower() not in ("", "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"]) md = f"""## {d['name']} *{d['size_type_alignment']}* **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} """ if _has_content(d.get("bonus_and_reactions")): md += f""" ### Bonus Actions & Reactions {d['bonus_and_reactions']} """ if _has_content(d.get("legendary_actions")): md += f""" ### Legendary Actions {d['legendary_actions']} """ return md def render_error(message: str) -> str: return ( '
{html.escape(message)}