dnd5-scripts/dice_roller/dice_roller.py
2026-06-17 22:32:48 +03:00

166 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
dice_roller.py — генератор бросков кубиков для D&D / настольных RPG.
Экспортирует функцию roll_dice(rolls), которую вызывает тул с фронта.
rolls — список объектов вида:
{
"dice": "2d6+1d8+3", # выражение: кубики и модификаторы
"label": "Урон меча", # назначение броска (необязательно)
"count": 2, # сколько раз повторить бросок (default 1)
}
Возвращает строку в формате Markdown.
"""
import re
import random
from typing_extensions import TypedDict
# ──────────────────────────────────────────────────────────────────────────────
# Типы
# ──────────────────────────────────────────────────────────────────────────────
class RollRequest(TypedDict, total=False):
"""
Один запрос на бросок.
Поля:
dice: Выражение броска в стандартной dice notation.
Поддерживаются:
- кубики: NdM (например, 2d6, 1d20, 4d4)
- сложение: 2d6+1d8+3
- вычитание: 1d20-2
- только модификатор: 5 (без кубиков)
Примеры: "1d20", "2d6+3", "2d6+1d8", "1d20-2+1d4"
label: Человекочитаемое назначение броска.
Примеры: "Атака орка", "Урон двуручным мечом", "Спасбросок Ловкости"
По умолчанию — пустая строка.
count: Сколько раз повторить этот бросок независимо.
Используется когда нужно несколько одинаковых бросков подряд
(например, 3 атаки одного существа: count=3).
По умолчанию 1.
"""
dice: str
label: str
count: int
# ──────────────────────────────────────────────────────────────────────────────
# Парсер и исполнитель выражения
# ──────────────────────────────────────────────────────────────────────────────
# Токен — это либо NdM, либо целое число, с опциональным знаком перед ним
_TOKEN_RE = re.compile(r'([+-]?)(\d+)(?:d(\d+))?', re.IGNORECASE)
def _parse_and_roll(expression: str) -> tuple[int, str]:
"""
Парсит dice notation и выполняет броски.
Поддерживает:
- кубики: 2d6, 1d20, 4d4
- модификаторы: +3, -2
- смешанные: 2d6+1d8+3, 1d20-2+1d4
Возвращает (итог: int, детали: str).
Детали — строка вида:
"2d6[3,5]+1d8[7]+3 = 18"
"""
expression = expression.strip().replace(' ', '')
# Нормализуем: если выражение начинается не со знака — ставим '+'
if expression and expression[0] not in ('+', '-'):
expression = '+' + expression
total = 0
parts = [] # Для читаемого вывода
for match in _TOKEN_RE.finditer(expression):
sign_str, num_str, die_str = match.groups()
sign = -1 if sign_str == '-' else 1
num = int(num_str)
if die_str is not None:
# Это кубик: num d die
die = int(die_str)
rolls = [random.randint(1, die) for _ in range(num)]
value = sum(rolls) * sign
sign_prefix = '-' if sign == -1 else ('+' if parts else '')
rolls_str = ','.join(str(r) for r in rolls)
parts.append(f"{sign_prefix}{num}d{die}[{rolls_str}]")
else:
# Это чистый модификатор
value = num * sign
sign_prefix = '-' if sign == -1 else ('+' if parts else '')
parts.append(f"{sign_prefix}{num}")
total += value
detail = ''.join(parts) + f" = **{total}**"
return total, detail
# ──────────────────────────────────────────────────────────────────────────────
# Публичный метод тула
# ──────────────────────────────────────────────────────────────────────────────
def roll_dice(rolls: list[RollRequest]) -> str:
"""
Основной метод, который вызывает тул с фронта.
Принимает список объектов-запросов RollRequest.
Для каждого объекта выполняет бросок(ки) и возвращает
Markdown-строку с детализацией результатов.
"""
if not rolls:
return "Список бросков пуст. Укажи хотя бы один бросок."
sections: list[str] = []
for idx, request in enumerate(rolls, start=1):
dice_expr = request.get("dice", "1d6").strip()
label = request.get("label", "").strip()
count = int(request.get("count", 1))
header_label = f"{label}" if label else ""
header = f"### Бросок {idx}{header_label} (`{dice_expr}`)"
lines = [header]
roll_total = 0
for j in range(1, count + 1):
total, detail = _parse_and_roll(dice_expr)
roll_total += total
prefix = f"#{j}: " if count > 1 else ""
lines.append(f"{prefix}{detail}")
if count > 1:
lines.append(f"**Сумма всех бросков: {roll_total}**")
sections.append("\n".join(lines))
return "\n\n---\n\n".join(sections)
# ──────────────────────────────────────────────────────────────────────────────
# Быстрый тест при запуске напрямую
# ──────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
result = roll_dice([
{"dice": "1d20+5", "label": "Атака орка", "count": 3},
{"dice": "2d6+1d8+2", "label": "Урон двуручным мечом", "count": 1},
{"dice": "1d20", "label": "Спасбросок Ловкости", "count": 1},
{"dice": "4d6", "label": "Генерация характеристик","count": 6},
{"dice": "1d20-2", "label": "Проверка с помехой", "count": 1},
])
print(result)