166 lines
7.7 KiB
Python
166 lines
7.7 KiB
Python
"""
|
||
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) |