feat: add TRPG roll command
This commit is contained in:
35
konabot/plugins/trpg_roll/__init__.py
Normal file
35
konabot/plugins/trpg_roll/__init__.py
Normal 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)
|
||||
143
konabot/plugins/trpg_roll/core.py
Normal file
143
konabot/plugins/trpg_roll/core.py
Normal 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)
|
||||
Reference in New Issue
Block a user