File size: 3,229 Bytes
ec4ba75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Quest 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, SCOPE_GUIDE, build_user_prompt
from .fixtures import FIXTURE_GULLWRACK

limiter = RateLimiter()

QUEST_TYPES = [
    "Rescue", "Retrieval", "Mystery", "Heist", "Escort", "Defense",
    "Exploration", "Faction Politics", "Monster Hunt", "Delivery Gone Wrong",
]
LEVELS = [str(i) for i in range(1, 21)]
SIZES = [str(i) for i in range(2, 8)]
TONES = [
    "Heroic", "Grim", "Whimsical", "Mysterious", "Morally gray", "Horror-tinged",
]
SCOPES = list(SCOPE_GUIDE.keys())  # One-shot, Short arc (2-3 sessions), Campaign thread

MAX_FIELD = 300
MAX_NOTES = 800


def randomize():
    """Fill the core inputs with a random runnable combination."""
    return (
        random.choice(QUEST_TYPES),
        random.choice(LEVELS),
        random.choice(TONES),
        random.choice(SCOPES),
    )


def _clip(value: str, limit: int) -> str:
    return (value or "").strip()[:limit]


def generate(
    quest_type, party_level, party_size, setting, tone, quest_giver, pc_tie,
    scope, notes, request=None,
):
    """Returns (markdown, plain_text, download_path, error_message)."""
    allowed, message = limiter.check(request)
    if not allowed:
        return None, None, None, message

    scope = scope if scope in SCOPE_GUIDE else "One-shot"
    fields = {
        "Quest type": _clip(quest_type, MAX_FIELD),
        "Party level": _clip(str(party_level or ""), 10),
        "Party size (players)": _clip(str(party_size or ""), 10),
        "Setting / region": _clip(setting, MAX_FIELD),
        "Tone": _clip(tone, 60),
        "Quest giver idea": _clip(quest_giver, MAX_FIELD),
        "Tie-in to a player character": _clip(pc_tie, MAX_FIELD),
        "Scope": scope,
        "DM notes": _clip(notes, MAX_NOTES),
    }
    user_prompt = build_user_prompt(fields) + "\n\n" + SCOPE_GUIDE[scope]

    try:
        data = ai_client.generate_json(
            SYSTEM_PROMPT, user_prompt, SCHEMA_SPEC,
            fixture=FIXTURE_GULLWRACK,
        )
    except ai_client.AIUnavailable as exc:
        return None, None, None, str(exc)

    md = render_markdown(data)
    path = Path(tempfile.mkdtemp()) / "dnd-quest.md"
    path.write_text(md, encoding="utf-8")
    return md, md, str(path), None


def render_markdown(d: dict) -> str:
    complications = "\n".join(
        f"{i}. {c}" for i, c in enumerate(d["complications"], 1)
    )
    followups = "\n".join(
        f"{i}. {h}" for i, h in enumerate(d["followup_hooks"], 1)
    )
    return f"""## {d['title']}

### The Hook
{d['hook']}

### Quest Giver
{d['quest_giver']}

### Objective
{d['objective']}

### Location
{d['location']}

### Three Complications
{complications}

### The Twist
{d['twist']}

### Antagonist
{d['antagonist']}

### Reward
{d['reward']}

### Where It Leads
{followups}

### DM Summary
{d['dm_summary']}
"""


def render_error(message: str) -> str:
    return (
        '<div class="lf-output" role="alert" style="border-color:#A7343B;">'
        f"<p>{html.escape(message)}</p></div>"
    )