Spaces:
Running
Running
File size: 3,679 Bytes
1d5c3e4 | 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 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | """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>"
)
|