144 lines
4.8 KiB
Python
144 lines
4.8 KiB
Python
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)
|