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