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)