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