Files
konabot/konabot/plugins/trpg_roll/core.py
2026-03-14 02:02:41 +08:00

144 lines
4.8 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.

import random
import re
from dataclasses import dataclass
MAX_DICE_COUNT = 100
MAX_DICE_FACES = 1000
MAX_TERM_COUNT = 20
MAX_TOTAL_ROLLS = 200
MAX_EXPRESSION_LENGTH = 200
MAX_MESSAGE_LENGTH = 1200
_TOKEN_RE = re.compile(r"([+-]?)(\d*d(?:%|[fF]|\d+)|\d+)")
_DICE_RE = re.compile(r"(?i)(\d*)d(%|f|\d+)")
# 常见跑团表达式示例3d6、d20+5、2d8+1d4+3、d%、4dF
class RollError(ValueError):
pass
@dataclass(slots=True)
class RollTermResult:
sign: int
source: str
detail: str
value: int
@dataclass(slots=True)
class RollResult:
expression: str
total: int
terms: list[RollTermResult]
def format(self) -> str:
parts = [f"{term.source}={term.detail}" for term in self.terms]
detail = " ".join(parts)
text = f"{self.expression} = {self.total}"
if detail:
text += f"\n{detail}"
if len(text) > MAX_MESSAGE_LENGTH:
raise RollError("结果过长,请减少骰子数量或简化表达式")
return text
def _parse_single_term(raw: str, sign: int, rng: random.Random) -> RollTermResult:
dice_match = _DICE_RE.fullmatch(raw)
if dice_match:
count_text, faces_text = dice_match.groups()
count = int(count_text) if count_text else 1
if count <= 0:
raise RollError("骰子个数必须大于 0")
if count > MAX_DICE_COUNT:
raise RollError(f"单项最多只能掷 {MAX_DICE_COUNT} 个骰子")
if faces_text == "%":
faces = 100
rolls = [rng.randint(1, 100) for _ in range(count)]
elif faces_text.lower() == "f":
rolls = [rng.choice((-1, 0, 1)) for _ in range(count)]
total = sum(rolls) * sign
signed = "+" if sign > 0 else "-"
return RollTermResult(
sign=sign,
source=f"{signed}{count}dF",
detail="[" + ", ".join(f"{v:+d}" for v in rolls) + "]",
value=total,
)
else:
faces = int(faces_text)
if faces <= 0:
raise RollError("骰子面数必须大于 0")
if faces > MAX_DICE_FACES:
raise RollError(f"骰子面数不能超过 {MAX_DICE_FACES}")
rolls = [rng.randint(1, faces) for _ in range(count)]
total = sum(rolls) * sign
signed = "+" if sign > 0 else "-"
return RollTermResult(
sign=sign,
source=f"{signed}{count}d{faces_text.upper()}",
detail="[" + ", ".join(map(str, rolls)) + "]",
value=total,
)
value = int(raw) * sign
signed = "+" if sign > 0 else "-"
return RollTermResult(sign=sign, source=f"{signed}{raw}", detail=str(abs(value)), value=value)
def roll_expression(expr: str, rng: random.Random | None = None) -> RollResult:
expr = expr.strip().replace(" ", "")
if not expr:
raise RollError("请提供要掷的表达式,例如 roll 3d6 或 roll d20+5")
if len(expr) > MAX_EXPRESSION_LENGTH:
raise RollError("表达式太长了")
matches = list(_TOKEN_RE.finditer(expr))
if not matches:
raise RollError("无法解析表达式,请使用如 3d6、d20+5、2d8+1d4+3、4dF 这样的格式")
rebuilt = "".join(m.group(0) for m in matches)
if rebuilt != expr:
raise RollError("表达式中含有无法识别的内容")
if len(matches) > MAX_TERM_COUNT:
raise RollError(f"表达式项数不能超过 {MAX_TERM_COUNT}")
total_rolls = 0
for m in matches:
token = m.group(2)
dice_match = _DICE_RE.fullmatch(token)
if dice_match:
count_text, faces_text = dice_match.groups()
count = int(count_text) if count_text else 1
if count <= 0:
raise RollError("骰子个数必须大于 0")
if count > MAX_DICE_COUNT:
raise RollError(f"单项最多只能掷 {MAX_DICE_COUNT} 个骰子")
if faces_text not in {"%", "f", "F"}:
faces = int(faces_text)
if faces <= 0:
raise RollError("骰子面数必须大于 0")
if faces > MAX_DICE_FACES:
raise RollError(f"骰子面数不能超过 {MAX_DICE_FACES}")
total_rolls += count
if total_rolls > MAX_TOTAL_ROLLS:
raise RollError(f"一次最多只能实际掷 {MAX_TOTAL_ROLLS} 个骰子")
rng = rng or random.Random()
terms: list[RollTermResult] = []
total = 0
for idx, match in enumerate(matches):
sign_text, raw = match.groups()
sign = -1 if sign_text == "-" else 1
if idx == 0 and sign_text == "":
sign = 1
term = _parse_single_term(raw, sign, rng)
terms.append(term)
total += term.value
return RollResult(expression=expr, total=total, terms=terms)