feat: add TRPG roll command

This commit is contained in:
2026-03-14 01:58:33 +08:00
parent e86a385448
commit a542ed1fd9
7 changed files with 355 additions and 0 deletions

View File

@ -0,0 +1,35 @@
import nonebot
from nonebot.adapters import Event
from nonebot_plugin_alconna import UniMessage, UniMsg
from konabot.common.permsys import register_default_allow_permission, require_permission
from konabot.plugins.trpg_roll.core import RollError, roll_expression
PERMISSION_KEY = "trpg.roll"
register_default_allow_permission(PERMISSION_KEY)
matcher = nonebot.on_message(
rule=require_permission(PERMISSION_KEY),
)
@matcher.handle()
async def _(event: Event, msg: UniMsg):
text = msg.extract_plain_text().strip()
lowered = text.lower()
if lowered == "roll" or lowered.startswith("roll "):
expr = text[4:].strip()
else:
return
if not expr:
await UniMessage.text("用法roll 3d6 / roll d20+5 / roll 2d8+1d4+3 / roll 4dF").send(event)
return
try:
result = roll_expression(expr)
await UniMessage.text(result.format()).send(event)
except RollError as e:
await UniMessage.text(str(e)).send(event)

View File

@ -0,0 +1,143 @@
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)