"""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 ( '" )