File size: 7,825 Bytes
f8ba6bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
"""
DungeonMaster AI - Input Validators

Utilities for validating user input and game data.
"""

import re
from typing import Any


class ValidationError(Exception):
    """Custom exception for validation errors."""

    def __init__(self, message: str, field: str | None = None):
        self.message = message
        self.field = field
        super().__init__(self.message)


def validate_dice_notation(notation: str) -> bool:
    """
    Validate dice notation format.

    Valid formats:
    - d20, D20
    - 2d6, 2D6
    - 1d8+5, 1d8-2
    - 4d6kh3 (keep highest 3)
    - 2d20kl1 (keep lowest 1, disadvantage)

    Args:
        notation: The dice notation string

    Returns:
        True if valid, False otherwise
    """
    # Pattern for standard dice notation with optional modifiers and keep syntax
    pattern = r"^(\d+)?[dD](\d+)(kh\d+|kl\d+)?([+-]\d+)?$"
    return bool(re.match(pattern, notation.strip()))


def validate_character_name(name: str) -> tuple[bool, str]:
    """
    Validate a character name.

    Args:
        name: The proposed character name

    Returns:
        Tuple of (is_valid, error_message or empty string)
    """
    if not name or not name.strip():
        return False, "Character name cannot be empty"

    name = name.strip()

    if len(name) < 2:
        return False, "Character name must be at least 2 characters"

    if len(name) > 50:
        return False, "Character name must be 50 characters or less"

    # Allow letters, spaces, apostrophes, and hyphens
    if not re.match(r"^[a-zA-Z][a-zA-Z\s'\-]*$", name):
        return False, "Character name must start with a letter and contain only letters, spaces, apostrophes, and hyphens"

    return True, ""


def validate_ability_score(score: int, method: str = "standard") -> tuple[bool, str]:
    """
    Validate an ability score.

    Args:
        score: The ability score value
        method: The generation method ("standard", "point_buy", "rolled")

    Returns:
        Tuple of (is_valid, error_message or empty string)
    """
    if not isinstance(score, int):
        return False, "Ability score must be an integer"

    if method == "point_buy":
        if score < 8 or score > 15:
            return False, "Point buy scores must be between 8 and 15"
    elif method == "standard":
        valid_scores = [8, 10, 12, 13, 14, 15]
        if score not in valid_scores:
            return False, f"Standard array scores must be one of {valid_scores}"
    else:  # rolled or other
        if score < 3 or score > 18:
            return False, "Rolled scores must be between 3 and 18"

    return True, ""


def validate_level(level: int) -> tuple[bool, str]:
    """
    Validate a character level.

    Args:
        level: The character level

    Returns:
        Tuple of (is_valid, error_message or empty string)
    """
    if not isinstance(level, int):
        return False, "Level must be an integer"

    if level < 1 or level > 20:
        return False, "Level must be between 1 and 20"

    return True, ""


def validate_hp(
    current: int, maximum: int, allow_negative: bool = False
) -> tuple[bool, str]:
    """
    Validate HP values.

    Args:
        current: Current HP
        maximum: Maximum HP
        allow_negative: Whether to allow negative current HP (for massive damage)

    Returns:
        Tuple of (is_valid, error_message or empty string)
    """
    if not isinstance(current, int) or not isinstance(maximum, int):
        return False, "HP values must be integers"

    if maximum < 1:
        return False, "Maximum HP must be at least 1"

    if not allow_negative and current < 0:
        return False, "Current HP cannot be negative"

    if allow_negative and current < -maximum:
        return False, "Current HP cannot be less than negative maximum HP"

    return True, ""


def validate_player_input(
    message: str, max_length: int = 1000
) -> tuple[bool, str, str]:
    """
    Validate and sanitize player input.

    Args:
        message: The player's input message
        max_length: Maximum allowed length

    Returns:
        Tuple of (is_valid, sanitized_message, error_message)
    """
    if not message:
        return False, "", "Please enter an action or message"

    # Strip and normalize whitespace
    sanitized = " ".join(message.split())

    if len(sanitized) > max_length:
        return False, "", f"Message is too long (max {max_length} characters)"

    # Check for potentially problematic content
    # (In a real app, might want more sophisticated content filtering)
    if sanitized.count("```") > 4:
        return False, sanitized, "Message contains too many code blocks"

    return True, sanitized, ""


def validate_session_data(data: dict[str, Any]) -> tuple[bool, str]:
    """
    Validate session/save data structure.

    Args:
        data: The session data dictionary

    Returns:
        Tuple of (is_valid, error_message or empty string)
    """
    required_fields = ["session_id", "started_at", "system"]

    for field in required_fields:
        if field not in data:
            return False, f"Missing required field: {field}"

    if data.get("system") not in ["dnd5e", "pathfinder2e", "call_of_cthulhu", "fate"]:
        return False, "Invalid game system"

    if "party" in data and not isinstance(data["party"], list):
        return False, "Party must be a list"

    if "game_state" in data:
        state = data["game_state"]
        if "in_combat" in state and not isinstance(state["in_combat"], bool):
            return False, "in_combat must be a boolean"

    return True, ""


def validate_adventure_data(data: dict[str, Any]) -> tuple[bool, str]:
    """
    Validate adventure JSON structure.

    Args:
        data: The adventure data dictionary

    Returns:
        Tuple of (is_valid, error_message or empty string)
    """
    if "metadata" not in data:
        return False, "Adventure must have metadata"

    metadata = data["metadata"]
    required_metadata = ["name", "description", "difficulty"]
    for field in required_metadata:
        if field not in metadata:
            return False, f"Metadata missing required field: {field}"

    if "starting_scene" not in data:
        return False, "Adventure must have a starting_scene"

    if "scenes" not in data or not isinstance(data["scenes"], list):
        return False, "Adventure must have a scenes array"

    if len(data["scenes"]) == 0:
        return False, "Adventure must have at least one scene"

    # Validate scene references
    scene_ids = {scene.get("scene_id") for scene in data["scenes"]}
    starting_id = data["starting_scene"].get("scene_id")
    if starting_id not in scene_ids:
        return False, f"Starting scene '{starting_id}' not found in scenes"

    return True, ""


def sanitize_for_tts(text: str) -> str:
    """
    Sanitize text for text-to-speech processing.

    Removes or converts elements that might cause TTS issues.

    Args:
        text: The raw text

    Returns:
        Sanitized text suitable for TTS
    """
    # Remove markdown formatting
    text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)  # Bold
    text = re.sub(r"\*(.+?)\*", r"\1", text)  # Italic
    text = re.sub(r"~~(.+?)~~", r"\1", text)  # Strikethrough
    text = re.sub(r"`(.+?)`", r"\1", text)  # Inline code
    text = re.sub(r"```[\s\S]*?```", "", text)  # Code blocks
    text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)  # Headers

    # Remove links, keep text
    text = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", text)

    # Remove emojis (basic pattern)
    text = re.sub(
        r"[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF\U0001F1E0-\U0001F1FF]",
        "",
        text,
    )

    # Normalize whitespace
    text = " ".join(text.split())

    return text.strip()