File size: 3,317 Bytes
7e2c39a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Statblock 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, build_user_prompt
from .fixtures import FIXTURE_RUSTBOUND_SENTINEL

limiter = RateLimiter()

CHALLENGE_RATINGS = ["1/8", "1/4", "1/2"] + [str(n) for n in range(1, 21)]
SIZES = ["Tiny", "Small", "Medium", "Large", "Huge", "Gargantuan"]
CREATURE_TYPES = [
    "Aberration", "Beast", "Celestial", "Construct", "Dragon", "Elemental",
    "Fey", "Fiend", "Giant", "Humanoid", "Monstrosity", "Ooze", "Plant", "Undead",
]

MAX_CONCEPT = 2000
MAX_FIELD = 300
MAX_EXTRA = 800


def randomize():
    """Fill the framing dropdowns randomly; the concept stays the user's."""
    return (
        random.choice(CHALLENGE_RATINGS),
        random.choice(SIZES),
        random.choice(CREATURE_TYPES),
    )


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


def generate(concept, cr, size, creature_type, legendary, extra, request=None):
    """Returns (markdown, plain_text, download_path, error_message)."""
    allowed, message = limiter.check(request)
    if not allowed:
        return None, None, None, message

    fields = {
        "Creature concept / homebrew notes": _clip(concept, MAX_CONCEPT),
        "Target challenge rating": _clip(cr, 20),
        "Size": _clip(size, 40),
        "Creature type": _clip(creature_type, MAX_FIELD),
        "Include legendary actions": "yes" if legendary else "no",
        "Extra mechanical requests": _clip(extra, MAX_EXTRA),
    }
    user_prompt = build_user_prompt(fields)

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

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


def _has_content(value) -> bool:
    """True unless the field is empty or the literal 'None' sentinel."""
    return (value or "").strip().rstrip(".").lower() not in ("", "none")


def render_markdown(d: dict) -> str:
    traits = "\n".join(f"- {t}" for t in d["traits"])
    actions = "\n".join(f"- {a}" for a in d["actions"])
    md = f"""## {d['name']}

*{d['size_type_alignment']}*

**Armor Class** {d['armor_class']}  \n**Hit Points** {d['hit_points']}  \n**Speed** {d['speed']}  \n**Ability Scores** {d['ability_scores']}  \n**Saves & Skills** {d['saves_and_skills']}  \n**Defenses** {d['defenses']}  \n**Senses & Languages** {d['senses_and_languages']}  \n**Challenge** {d['challenge']}

### Traits
{traits}

### Actions
{actions}
"""
    if _has_content(d.get("bonus_and_reactions")):
        md += f"""
### Bonus Actions & Reactions
{d['bonus_and_reactions']}
"""
    if _has_content(d.get("legendary_actions")):
        md += f"""
### Legendary Actions
{d['legendary_actions']}
"""
    return md


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