Spaces:
Running
Running
| """NPC 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, IMPORTANCE_GUIDE, build_user_prompt | |
| from .fixtures import FIXTURE_LOCK_KEEPER | |
| limiter = RateLimiter() | |
| RACES = [ | |
| "Human", "Elf", "Half-Elf", "Dwarf", "Halfling", "Dragonborn", "Tiefling", | |
| "Half-Orc", "Orc", "Gnome", "Goliath", "Aasimar", "Tabaxi", "Kenku", "Firbolg", | |
| ] | |
| IMPORTANCE = list(IMPORTANCE_GUIDE.keys()) # Background, Recurring, Major | |
| DEMEANORS = [ | |
| "Friendly", "Gruff", "Nervous", "Scheming", "Weary", "Cheerful", | |
| "Menacing", "Aloof", | |
| ] | |
| ROLES = [ | |
| "canal lock-keeper", "harbor-master", "back-alley fence", "village priest", | |
| "caravan quartermaster", "lighthouse keeper", "guild debt-collector", | |
| "street surgeon", "gravedigger who hears things", "tavern cook and informant", | |
| "border toll-taker", "royal rat-catcher", "pit-fight promoter", | |
| "traveling tooth-puller", "archive clerk with a grudge", | |
| "ferryman on the night crossing", "disgraced court astrologer", | |
| "reliquary guard", "salt-mine overseer", "letter-carrier between enemy houses", | |
| ] | |
| MAX_FIELD = 300 | |
| MAX_NOTES = 800 | |
| def randomize(): | |
| """Fill the core inputs with a random playable combination.""" | |
| return ( | |
| random.choice(ROLES), | |
| random.choice(RACES), | |
| random.choice(IMPORTANCE), | |
| random.choice(DEMEANORS), | |
| ) | |
| def _clip(value: str, limit: int) -> str: | |
| return (value or "").strip()[:limit] | |
| def generate( | |
| role, race, importance, location, demeanor, setting, party_need, notes, | |
| request=None, | |
| ): | |
| """Returns (markdown, plain_text, download_path, error_message).""" | |
| allowed, message = limiter.check(request) | |
| if not allowed: | |
| return None, None, None, message | |
| importance = importance if importance in IMPORTANCE_GUIDE else "Recurring" | |
| fields = { | |
| "Role / occupation": _clip(role, MAX_FIELD), | |
| "Race / species": _clip(race, MAX_FIELD), | |
| "Importance to the campaign": importance, | |
| "Location / context where the party meets them": _clip(location, MAX_FIELD), | |
| "Demeanor": _clip(demeanor, 40), | |
| "Campaign setting": _clip(setting, MAX_FIELD), | |
| "What the party needs from them": _clip(party_need, MAX_FIELD), | |
| "DM notes": _clip(notes, MAX_NOTES), | |
| } | |
| user_prompt = build_user_prompt(fields) + "\n\n" + IMPORTANCE_GUIDE[importance] | |
| try: | |
| data = ai_client.generate_json( | |
| SYSTEM_PROMPT, user_prompt, SCHEMA_SPEC, | |
| fixture=FIXTURE_LOCK_KEEPER, | |
| ) | |
| except ai_client.AIUnavailable as exc: | |
| return None, None, None, str(exc) | |
| md = render_markdown(data) | |
| path = Path(tempfile.mkdtemp()) / "dnd-npc.md" | |
| path.write_text(md, encoding="utf-8") | |
| return md, md, str(path), None | |
| def render_markdown(d: dict) -> str: | |
| hooks = "\n".join(f"{i}. {h}" for i, h in enumerate(d["plot_hooks"], 1)) | |
| quote = d["quote"].strip().strip('"') | |
| return f"""## {d['name']} | |
| *{d['role_title']}* | |
| ### Appearance | |
| {d['appearance']} | |
| ### Personality | |
| {d['personality']} | |
| ### Voice & Mannerism | |
| {d['voice_and_mannerism']} | |
| ### Motivation | |
| {d['motivation']} | |
| ### Secret | |
| {d['secret']} | |
| ### Relationships | |
| {d['relationships']} | |
| > "{quote}" | |
| ### Three Plot Hooks | |
| {hooks} | |
| ### Stat Suggestion | |
| {d['stat_suggestion']} | |
| ### Quick Reference | |
| {d['quick_reference']} | |
| """ | |
| def render_error(message: str) -> str: | |
| return ( | |
| '<div class="lf-output" role="alert" style="border-color:#A7343B;">' | |
| f"<p>{html.escape(message)}</p></div>" | |
| ) | |