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