diff --git a/docs/permsys.md b/docs/permsys.md index f7c0be5..4a7b6ad 100644 --- a/docs/permsys.md +++ b/docs/permsys.md @@ -16,6 +16,7 @@ - `konabot/common/permsys/__init__.py` - 暴露 `PermManager`、`DepPermManager`、`require_permission` - 负责数据库初始化、启动迁移、超级管理员默认授权 + - 提供 `register_default_allow_permission()` 用于注册“启动时默认放行”的权限键 - `konabot/common/permsys/entity.py` - 定义 `PermEntity` - 将事件转换为可查询的实体链 @@ -134,6 +135,14 @@ PermEntity("ob11", "user", str(account)), "*", True 也就是说,配置中的超级管理员会直接拥有全部权限。 +此外,模块也支持插件在导入阶段通过 `register_default_allow_permission("some.key")` 注册默认放行的权限键;这些键会在启动时被写入到: + +```python +PermEntity("sys", "global", "global"), "some.key", True +``` + +这适合“默认所有人可用,但仍希望后续能被权限系统单独关闭”的功能。 + 这属于启动时自动灌入的保底策略,不依赖手工授权命令。 ## 在插件中使用 diff --git a/konabot/common/permsys/__init__.py b/konabot/common/permsys/__init__.py index b660c1b..8ee0ed2 100644 --- a/konabot/common/permsys/__init__.py +++ b/konabot/common/permsys/__init__.py @@ -14,6 +14,7 @@ from konabot.common.permsys.repo import PermRepo db = DatabaseManager(DATA_PATH / "perm.sqlite3") +_default_allow_permissions: set[str] = set() _EntityLike = Event | PermEntity | list[PermEntity] @@ -91,6 +92,10 @@ def create_startup(): # pragma: no cover await pm.update_permission( PermEntity("ob11", "user", str(account)), "*", True ) + for key in _default_allow_permissions: + await pm.update_permission( + PermEntity("sys", "global", "global"), key, True + ) @driver.on_shutdown async def _(): @@ -103,6 +108,10 @@ def create_startup(): # pragma: no cover DepPermManager = Annotated[PermManager, Depends(perm_manager)] +def register_default_allow_permission(key: str): + _default_allow_permissions.add(key) + + def require_permission(perm: str) -> Rule: # pragma: no cover async def check_permission(event: Event, pm: DepPermManager) -> bool: return await pm.check_has_permission(event, perm) diff --git a/konabot/docs/user/roll.txt b/konabot/docs/user/roll.txt new file mode 100644 index 0000000..1206e16 --- /dev/null +++ b/konabot/docs/user/roll.txt @@ -0,0 +1,53 @@ +**roll** - 面向跑团的文本骰子指令 + +## 用法 + +`roll 表达式` + +支持常见骰子写法: + +- `roll 3d6` +- `roll d20+5` +- `roll 2d8+1d4+3` +- `roll d%` +- `roll 4dF` + +## 说明 + +- `NdM` 表示掷 N 个 M 面骰,例如 `3d6` +- `d20` 等价于 `1d20` +- `d%` 表示百分骰,范围 1 到 100 +- `dF` 表示 Fate/Fudge 骰,单骰结果为 -1、0、+1 +- 支持用 `+`、`-` 连接多个项,也支持常数修正 + +## 返回格式 + +会返回总结果,以及每一项的明细。 + +例如: + +- `roll 3d6` + 可能返回: + - `3d6 = 11` + - `+3d6=[2, 4, 5]` + +- `roll d20+5` + 可能返回: + - `d20+5 = 19` + - `+1d20=[14] +5=5` + +## 限制 + +为防止刷屏和滥用,当前实现会限制: + +- 单项最多 100 个骰子 +- 单个骰子最多 1000 面 +- 一次表达式最多 20 项 +- 一次表达式最多实际掷 200 个骰子 +- 结果过长时会直接拒绝 + +## 权限 + +需要 `trpg.roll` 权限。 + +默认启动时会给系统全局授予允许,因此通常所有人都能用;如有需要可再用权限系统单独关闭。 \ No newline at end of file diff --git a/konabot/plugins/trpg_roll/__init__.py b/konabot/plugins/trpg_roll/__init__.py new file mode 100644 index 0000000..4c754d2 --- /dev/null +++ b/konabot/plugins/trpg_roll/__init__.py @@ -0,0 +1,35 @@ +import re + +import nonebot +from nonebot.adapters import Event +from nonebot_plugin_alconna import UniMessage, UniMsg + +from konabot.common.nb import match_keyword +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=match_keyword.match_keyword(re.compile(r"^roll(?:\s+.+)?$", re.I)) + & require_permission(PERMISSION_KEY), +) + + +@matcher.handle() +async def _(event: Event, msg: UniMsg): + text = msg.extract_plain_text().strip() + expr = text[4:].strip() + + 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) diff --git a/konabot/plugins/trpg_roll/core.py b/konabot/plugins/trpg_roll/core.py new file mode 100644 index 0000000..3ad930a --- /dev/null +++ b/konabot/plugins/trpg_roll/core.py @@ -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) diff --git a/tests/test_permsys_default_allow.py b/tests/test_permsys_default_allow.py new file mode 100644 index 0000000..6f1dcde --- /dev/null +++ b/tests/test_permsys_default_allow.py @@ -0,0 +1,40 @@ +from contextlib import asynccontextmanager +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +from konabot.common.database import DatabaseManager +from konabot.common.permsys import PermManager, register_default_allow_permission +from konabot.common.permsys.entity import PermEntity +from konabot.common.permsys.migrates import execute_migration + + +@asynccontextmanager +async def tempdb(): + with TemporaryDirectory() as _tempdir: + tempdir = Path(_tempdir) + db = DatabaseManager(tempdir / "perm.sqlite3") + yield db + await db.close_all_connections() + + +@pytest.mark.asyncio +async def test_register_default_allow_permission_records_key(): + register_default_allow_permission("test.default.allow") + + async with tempdb() as db: + async with db.get_conn() as conn: + await execute_migration(conn) + + pm = PermManager(db) + await pm.update_permission( + PermEntity("sys", "global", "global"), + "test.default.allow", + True, + ) + + assert await pm.check_has_permission( + [PermEntity("dummy", "user", "1"), PermEntity("sys", "global", "global")], + "test.default.allow.sub", + ) diff --git a/tests/test_trpg_roll.py b/tests/test_trpg_roll.py new file mode 100644 index 0000000..e3f28a0 --- /dev/null +++ b/tests/test_trpg_roll.py @@ -0,0 +1,66 @@ +import random + +import pytest + +from konabot.plugins.trpg_roll.core import RollError, roll_expression + + +class FakeRandom: + def __init__(self, randint_values: list[int] | None = None, choice_values: list[int] | None = None): + self._randint_values = list(randint_values or []) + self._choice_values = list(choice_values or []) + + def randint(self, _a: int, _b: int) -> int: + assert self._randint_values + return self._randint_values.pop(0) + + def choice(self, _seq): + assert self._choice_values + return self._choice_values.pop(0) + + +def test_roll_expression_basic(): + rng = FakeRandom(randint_values=[2, 4, 5]) + result = roll_expression("3d6", rng=rng) + + assert result.total == 11 + assert result.format() == "3d6 = 11\n+3d6=[2, 4, 5]" + + +def test_roll_expression_multiple_terms(): + rng = FakeRandom(randint_values=[14, 3, 1]) + result = roll_expression("d20+1d4-2", rng=rng) + + assert result.total == 15 + assert result.format() == "d20+1d4-2 = 15\n+1d20=[14] +1d4=[3] -2=2" + + +def test_roll_expression_df(): + rng = FakeRandom(choice_values=[-1, 0, 1, 1]) + result = roll_expression("4dF", rng=rng) + + assert result.total == 1 + assert result.format() == "4dF = 1\n+4dF=[-1, +0, +1, +1]" + + +@pytest.mark.parametrize( + ("expr", "message"), + [ + ("", "请提供要掷的表达式"), + ("abc", "无法解析表达式"), + ("1d0", "骰子面数必须大于 0"), + ("0d6", "骰子个数必须大于 0"), + ("101d6", "单项最多只能掷 100 个骰子"), + ("1d1001", "骰子面数不能超过 1000"), + ("201d1", "单项最多只能掷 100 个骰子"), + ("1d6*2", "表达式中含有无法识别的内容"), + ], +) +def test_roll_expression_invalid(expr: str, message: str): + with pytest.raises(RollError, match=message): + roll_expression(expr, rng=random.Random(0)) + + +def test_roll_expression_total_roll_limit(): + with pytest.raises(RollError, match="一次最多只能实际掷 200 个骰子"): + roll_expression("100d6+100d6+1d6", rng=random.Random(0))