File size: 3,317 Bytes
7e2c39a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 | """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>"
)
|