aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src/oneroll
diff options
context:
space:
mode:
author简律纯 <i@jyunko.cn>2025-09-12 04:02:02 +0800
committer简律纯 <i@jyunko.cn>2025-09-12 04:02:02 +0800
commit0288d0956330d5ac8db48b752240f723e8703929 (patch)
tree0297dee9f8166af0a856dd3a1057ad5f25f14c6a /src/oneroll
parent5135876b5e2a6c40232414ea0b7eb875fa225cf0 (diff)
downloadOneRoll-0288d0956330d5ac8db48b752240f723e8703929.tar.gz
OneRoll-0288d0956330d5ac8db48b752240f723e8703929.zip
feat: initial basic roll features
Diffstat (limited to 'src/oneroll')
-rw-r--r--src/oneroll/__init__.py241
-rw-r--r--src/oneroll/__main__.py335
-rw-r--r--src/oneroll/_core.pyi247
-rw-r--r--src/oneroll/grammar.pest43
-rw-r--r--src/oneroll/tui.py251
5 files changed, 1117 insertions, 0 deletions
diff --git a/src/oneroll/__init__.py b/src/oneroll/__init__.py
new file mode 100644
index 0000000..a57cfee
--- /dev/null
+++ b/src/oneroll/__init__.py
@@ -0,0 +1,241 @@
+"""
+OneRoll - High Performance Dice Expression Parser
+
+A dice expression parser implemented in Rust and bound to Python through PyO3.
+Supports complex dice expression parsing, various modifiers and mathematical operations.
+
+Main functions:
+- Basic Dice Rolling (XdY)
+- Mathematical operations (+, -, *, /, ^)
+- Modifiers support (!, kh, kl, dh, dl, r, ro)
+- Bracket support
+- Complete error handling
+
+Example of usage:
+# Basic use
+import oneroll
+result = oneroll.roll("3d6 + 2")
+print(result.total) # output total points
+
+# Use the OneRoll class
+roller = oneroll.OneRoll()
+result = roller.roll("4d6kh3")
+
+# Simple throw
+total = oneroll.roll_simple(3, 6)
+"""
+
+from typing import Dict, List, Any, Union, Optional
+from ._core import (
+ OneRoll as _OneRoll,
+ roll_dice as _roll_dice,
+ roll_simple as _roll_simple,
+)
+
+__version__ = "1.0.1"
+__author__ = "HsiangNianian"
+__description__ = "高性能骰子表达式解析器"
+
+# 重新导出主要类和函数,提供更友好的接口
+class OneRoll:
+ """
+ OneRoll 骰子投掷器类
+
+ 提供面向对象的骰子投掷接口,支持复杂表达式和各种修饰符。
+
+ 示例:
+ roller = OneRoll()
+ result = roller.roll("3d6 + 2")
+ simple_result = roller.roll_simple(3, 6)
+ modifier_result = roller.roll_with_modifiers(4, 6, ["kh3"])
+ """
+
+ def __init__(self):
+ """初始化 OneRoll 实例"""
+ self._roller = _OneRoll()
+
+ def roll(self, expression: str) -> Dict[str, Any]:
+ """
+ 解析并计算骰子表达式
+
+ Args:
+ expression: 骰子表达式字符串,如 "3d6 + 2", "4d6kh3", "2d6! # 攻击投掷"
+
+ Returns:
+ 包含以下键的字典:
+ - expression: 表达式字符串
+ - total: 总点数
+ - rolls: 投掷结果列表
+ - details: 详细信息
+ - comment: 用户注释
+
+ Raises:
+ ValueError: 当表达式无效时
+
+ Example:
+ result = roller.roll("3d6 + 2")
+ print(f"总点数: {result['total']}")
+ print(f"详情: {result['details']}")
+ """
+ return self._roller.roll(expression)
+
+ def roll_simple(self, dice_count: int, dice_sides: int) -> int:
+ """
+ 简单骰子投掷
+
+ Args:
+ dice_count: 骰子数量
+ dice_sides: 骰子面数
+
+ Returns:
+ 总点数
+
+ Raises:
+ ValueError: 当参数无效时
+
+ Example:
+ total = roller.roll_simple(3, 6) # 投掷 3d6
+ """
+ return self._roller.roll_simple(dice_count, dice_sides)
+
+ def roll_with_modifiers(
+ self, dice_count: int, dice_sides: int, modifiers: List[str]
+ ) -> Dict[str, Any]:
+ """
+ 带修饰符的骰子投掷
+
+ Args:
+ dice_count: 骰子数量
+ dice_sides: 骰子面数
+ modifiers: 修饰符列表,如 ["kh3", "!"]
+
+ Returns:
+ 包含 total, rolls, details 的字典
+
+ Raises:
+ ValueError: 当参数或修饰符无效时
+
+ Example:
+ result = roller.roll_with_modifiers(4, 6, ["kh3"]) # 4d6kh3
+ """
+ return self._roller.roll_with_modifiers(dice_count, dice_sides, modifiers)
+
+
+# 便捷函数
+def roll(expression: str) -> Dict[str, Any]:
+ """
+ 解析并计算骰子表达式(便捷函数)
+
+ Args:
+ expression: 骰子表达式字符串,支持注释
+
+ Returns:
+ 投掷结果字典,包含 comment 字段
+
+ Example:
+ result = oneroll.roll("3d6 + 2 # 攻击投掷")
+ print(result["comment"]) # 输出: "攻击投掷"
+ """
+ return _roll_dice(expression)
+
+
+def roll_simple(dice_count: int, dice_sides: int) -> int:
+ """
+ 简单骰子投掷(便捷函数)
+
+ Args:
+ dice_count: 骰子数量
+ dice_sides: 骰子面数
+
+ Returns:
+ 总点数
+
+ Example:
+ total = oneroll.roll_simple(3, 6)
+ """
+ return _roll_simple(dice_count, dice_sides)
+
+
+def roll_multiple(expression: str, times: int) -> List[Dict[str, Any]]:
+ """
+ 多次投掷同一个表达式
+
+ Args:
+ expression: 骰子表达式字符串
+ times: 投掷次数
+
+ Returns:
+ 投掷结果列表
+
+ Example:
+ results = oneroll.roll_multiple("3d6", 10)
+ totals = [r['total'] for r in results]
+ """
+ return [_roll_dice(expression) for _ in range(times)]
+
+
+def roll_statistics(expression: str, times: int) -> Dict[str, Union[int, float]]:
+ """
+ 统计多次投掷的结果
+
+ Args:
+ expression: 骰子表达式字符串
+ times: 投掷次数
+
+ Returns:
+ 包含统计信息的字典
+
+ Example:
+ stats = oneroll.roll_statistics("3d6", 100)
+ print(f"平均值: {stats['mean']:.2f}")
+ """
+ results = roll_multiple(expression, times)
+ totals = [r["total"] for r in results]
+
+ return {
+ "min": min(totals),
+ "max": max(totals),
+ "mean": sum(totals) / len(totals),
+ "total": sum(totals),
+ "count": len(totals),
+ "results": totals,
+ }
+
+
+# 常用骰子表达式
+class CommonRolls:
+ """常用骰子表达式常量"""
+
+ # D&D 常用投掷
+ D20 = "1d20"
+ D20_ADVANTAGE = "2d20kh1"
+ D20_DISADVANTAGE = "2d20kl1"
+
+ # 属性投掷
+ ATTRIBUTE_ROLL = "4d6kh3"
+
+ # 伤害投掷
+ D6_DAMAGE = "1d6"
+ D8_DAMAGE = "1d8"
+ D10_DAMAGE = "1d10"
+ D12_DAMAGE = "1d12"
+
+ # 生命值
+ HIT_POINTS_D6 = "1d6"
+ HIT_POINTS_D8 = "1d8"
+ HIT_POINTS_D10 = "1d10"
+ HIT_POINTS_D12 = "1d12"
+
+
+# 导出公共接口
+__all__ = [
+ "OneRoll",
+ "roll",
+ "roll_simple",
+ "roll_multiple",
+ "roll_statistics",
+ "CommonRolls",
+ "__version__",
+ "__author__",
+ "__description__",
+]
diff --git a/src/oneroll/__main__.py b/src/oneroll/__main__.py
new file mode 100644
index 0000000..f454df8
--- /dev/null
+++ b/src/oneroll/__main__.py
@@ -0,0 +1,335 @@
+#!/usr/bin/env python3
+"""
+OneRoll interactive dice roll program
+
+Supports command line parameters and interactive mode.
+
+Example of usage:
+# Direct throw
+python -m oneroll "3d6 + 2"
+
+# Interactive mode
+python -m oneroll
+
+# Statistical Mode
+python -m oneroll --stats "3d6" --times 100
+"""
+
+import sys
+import argparse
+from typing import List, Dict, Any
+from rich.console import Console
+from rich.table import Table
+from rich.panel import Panel
+from rich.text import Text
+from rich.prompt import Prompt, Confirm
+from rich.progress import Progress, SpinnerColumn, TextColumn
+
+from . import OneRoll, roll, roll_simple, roll_multiple, roll_statistics, CommonRolls
+
+console = Console()
+
+class OneRollCLI:
+ """OneRoll command line interface"""
+
+ def __init__(self):
+ self.roller = OneRoll()
+ self.history: List[Dict[str, Any]] = []
+
+ def print_result(self, result: Dict[str, Any], expression: str = None):
+ """Pretty print the dice roll result"""
+ if expression is None:
+ expression = result.get('expression', 'Unknown')
+
+ # Create result panel
+ total = result['total']
+ details = result['details']
+ rolls = result['rolls']
+
+ # Select color based on result value
+ if total >= 15:
+ color = "green"
+ elif total >= 10:
+ color = "yellow"
+ else:
+ color = "red"
+
+ # Build display text
+ text = Text()
+ text.append(f"🎲 {expression}\n", style="bold blue")
+ text.append(f"总点数: ", style="bold")
+ text.append(f"{total}", style=f"bold {color}")
+ text.append(f"\n详情: {details}", style="white")
+
+ if rolls:
+ text.append(f"\n投掷结果: ", style="bold")
+ text.append(f"{rolls}", style="cyan")
+
+ # Display comment
+ comment = result.get("comment", "")
+ if comment:
+ text.append(f"\n注释: ", style="bold")
+ text.append(f"{comment}", style="italic blue")
+
+ panel = Panel(text, title="Dice Roll Result", border_style=color)
+ console.print(panel)
+
+ def print_statistics(self, stats: Dict[str, Any], expression: str):
+ """Print statistics information"""
+ table = Table(title=f"统计结果: {expression} (投掷 {stats['count']} 次)")
+ table.add_column("统计项", style="cyan")
+ table.add_column("数值", style="green")
+
+ table.add_row("最小值", str(stats['min']))
+ table.add_row("最大值", str(stats['max']))
+ table.add_row("平均值", f"{stats['mean']:.2f}")
+ table.add_row("总和", str(stats['total']))
+ table.add_row("投掷次数", str(stats['count']))
+
+ console.print(table)
+
+ def print_history(self):
+ """Print dice roll history"""
+ if not self.history:
+ console.print("暂无投掷历史", style="yellow")
+ return
+
+ table = Table(title="投掷历史")
+ table.add_column("序号", style="cyan")
+ table.add_column("表达式", style="green")
+ table.add_column("总点数", style="yellow")
+ table.add_column("注释", style="blue")
+ table.add_column("详情", style="white")
+
+ for i, result in enumerate(self.history[-10:], 1): # only show last 30 times
+ table.add_row(
+ str(i),
+ result.get('expression', 'Unknown'),
+ str(result['total']),
+ result.get('comment', ''),
+ result['details']
+ )
+
+ console.print(table)
+
+ def show_help(self):
+ """show help information"""
+ help_text = """
+🎲 OneRoll 骰子表达式解析器
+
+支持的表达式格式:
+• 基本骰子: 3d6, 1d20, 2d10
+• 数学运算: 3d6 + 2, 2d6 * 3, (2d6 + 3) * 2
+• 修饰符:
+ - ! 爆炸骰子: 2d6!
+ - kh 取高: 4d6kh3
+ - kl 取低: 4d6kl2
+ - dh 丢弃高: 5d6dh1
+ - dl 丢弃低: 5d6dl1
+ - r 重投: 3d6r1
+ - ro 条件重投: 4d6ro1
+• 注释: 在表达式末尾使用 # 添加注释
+
+常用命令:
+• help - 显示帮助
+• history - 显示投掷历史
+• stats <表达式> <次数> - 统计投掷
+• clear - 清空历史
+• quit/exit - 退出程序
+
+常用表达式:
+• d20 - 1d20
+• advantage - 2d20kh1 (优势)
+• disadvantage - 2d20kl1 (劣势)
+• attribute - 4d6kh3 (属性投掷)
+ """
+
+ console.print(Panel(help_text, title="帮助信息", border_style="blue"))
+
+ def interactive_mode(self):
+ """Interactive Mode"""
+ console.print(Panel.fit("🎲 OneRoll 交互式掷骰程序", style="bold blue"))
+ console.print("输入 'help' 查看帮助,输入 'quit' 退出程序\n")
+
+ while True:
+ try:
+ user_input = Prompt.ask("🎲 请输入骰子表达式").strip()
+
+ if not user_input:
+ continue
+
+ # handle special command
+ if user_input.lower() in ['quit', 'exit', 'q']:
+ if Confirm.ask("确定要退出吗?"):
+ break
+ continue
+
+ if user_input.lower() == 'help':
+ self.show_help()
+ continue
+
+ if user_input.lower() == 'history':
+ self.print_history()
+ continue
+
+ if user_input.lower() == 'clear':
+ self.history.clear()
+ console.print("历史已清空", style="green")
+ continue
+
+ if user_input.lower().startswith('stats '):
+ parts = user_input.split()
+ if len(parts) >= 3:
+ expression = parts[1]
+ try:
+ times = int(parts[2])
+ if times > 1000:
+ console.print("统计次数不能超过1000次", style="red")
+ continue
+
+ with Progress(
+ SpinnerColumn(),
+ TextColumn("[progress.description]{task.description}"),
+ console=console
+ ) as progress:
+ task = progress.add_task(f"正在统计 {expression}...", total=None)
+ stats = roll_statistics(expression, times)
+ progress.stop()
+
+ self.print_statistics(stats, expression)
+ except ValueError:
+ console.print("统计次数必须是数字", style="red")
+ except Exception as e:
+ console.print(f"统计错误: {e}", style="red")
+ else:
+ console.print("用法: stats <表达式> <次数>", style="red")
+ continue
+
+ # resolve regular expression's alias
+ expression = self._resolve_expression_alias(user_input)
+
+ # execute roll
+ try:
+ result = roll(expression)
+ self.history.append(result)
+ self.print_result(result, user_input)
+ except Exception as e:
+ console.print(f"错误: {e}", style="red")
+
+ except KeyboardInterrupt:
+ if Confirm.ask("\n确定要退出吗?"):
+ break
+ except EOFError:
+ break
+
+ def _resolve_expression_alias(self, user_input: str) -> str:
+ """resolve regular expression's alias"""
+ aliases = {
+ 'd20': CommonRolls.D20,
+ 'advantage': CommonRolls.D20_ADVANTAGE,
+ 'disadvantage': CommonRolls.D20_DISADVANTAGE,
+ 'attr': CommonRolls.ATTRIBUTE_ROLL,
+ 'attribute': CommonRolls.ATTRIBUTE_ROLL,
+ }
+
+ return aliases.get(user_input.lower(), user_input)
+
+ def run(self, args):
+ """run tui mode"""
+ if args.tui:
+ # start tui
+ try:
+ from .tui import run_tui
+ run_tui()
+ except ImportError:
+ console.print("TUI 模式需要安装 textual: pip install textual", style="red")
+ sys.exit(1)
+ except Exception as e:
+ console.print(f"TUI 启动失败: {e}", style="red")
+ sys.exit(1)
+
+ elif args.expression:
+ # single roll mode
+ try:
+ result = roll(args.expression)
+ self.print_result(result)
+ except Exception as e:
+ console.print(f"错误: {e}", style="red")
+ sys.exit(1)
+
+ elif args.stats:
+ # stats mode
+ try:
+ times = args.times or 100
+ if times > 10000:
+ console.print("统计次数不能超过10000次", style="red")
+ sys.exit(1)
+
+ with Progress(
+ SpinnerColumn(),
+ TextColumn("[progress.description]{task.description}"),
+ console=console
+ ) as progress:
+ task = progress.add_task(f"正在统计 {args.stats}...", total=None)
+ stats = roll_statistics(args.stats, times)
+ progress.stop()
+
+ self.print_statistics(stats, args.stats)
+ except Exception as e:
+ console.print(f"错误: {e}", style="red")
+ sys.exit(1)
+
+ else:
+ # interactive mode
+ self.interactive_mode()
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="OneRoll 骰子表达式解析器",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+使用示例:
+ %(prog)s "3d6 + 2" # 单次投掷
+ %(prog)s --stats "3d6" --times 100 # 统计模式
+ %(prog)s --tui # 终端用户界面
+ %(prog)s # 交互式模式
+ """
+ )
+
+ parser.add_argument(
+ 'expression',
+ nargs='?',
+ help='骰子表达式,如 "3d6 + 2"'
+ )
+
+ parser.add_argument(
+ '--stats',
+ help='统计模式,指定要统计的表达式'
+ )
+
+ parser.add_argument(
+ '--times',
+ type=int,
+ default=100,
+ help='统计次数,默认100次'
+ )
+
+ parser.add_argument(
+ '--version',
+ action='version',
+ version='OneRoll 0.0.1'
+ )
+
+ parser.add_argument(
+ '--tui',
+ action='store_true',
+ help='启动终端用户界面 (TUI)'
+ )
+
+ args = parser.parse_args()
+
+ cli = OneRollCLI()
+ cli.run(args)
+
+if __name__ == '__main__':
+ main() \ No newline at end of file
diff --git a/src/oneroll/_core.pyi b/src/oneroll/_core.pyi
new file mode 100644
index 0000000..7916261
--- /dev/null
+++ b/src/oneroll/_core.pyi
@@ -0,0 +1,247 @@
+from typing import Dict, List, Any, Union
+
+DiceResult = Dict[str, Any]
+RollHistory = List[DiceResult]
+ModifierList = List[str]
+
+def roll_dice(expression: str) -> DiceResult:
+ """
+ Analyze and calculate dice expressions
+
+ Args:
+ expression: Dice expression string, supports the following formats:
+ - Basic dice: "3d6", "1d20", "2d10"
+ - Mathematical operations: "3d6 + 2", "2d6 * 3", "(2d6 + 3) * 2"
+ - Modifiers: "2d6!", "4d6kh3", "5d6dl1", "3d6r1", "4d6ro1"
+
+ Returns:
+ A dictionary containing the following keys:
+ - expression: str - expression string
+ - total: int - Total points
+ - rolls: List[List[int]] - List of throw results
+ - details: str - details
+ - comment: str - User comment
+
+ Raises:
+ ValueError: When the expression is invalid
+
+ Example:
+ result = roll_dice("3d6 + 2")
+ print(result["total"]) # output total points
+ """
+ ...
+
+def roll_simple(dice_count: int, dice_sides: int) -> int:
+ """
+ Simple dice throw
+
+ Args:
+ dice_count: The number of dice must be greater than 0
+ dice_sides: The number of dice faces must be greater than 0
+
+ Returns:
+ Total points
+
+ Raises:
+ ValueError: When the parameter is invalid
+
+ Example:
+ total = roll_simple(3, 6) # Roll 3 6-sided dice
+ """
+ ...
+
+class OneRoll:
+ """
+ OneRoll dice thrower category
+
+ Provides an object-oriented dice throwing interface, supporting complex expressions and various modifiers.
+ """
+
+ def __init__(self) -> None:
+ """
+ Initialize OneRoll Object
+
+ Example:
+ roller = OneRoll()
+ """
+ ...
+
+ def roll(self, expression: str) -> DiceResult:
+ """
+ Analyze and calculate dice expressions
+
+ Args:
+ expression: Dice expression string, supports the following formats:
+ - Basic dice: "3d6", "1d20", "2d10"
+ - Mathematical operations: "3d6 + 2", "2d6 * 3", "(2d6 + 3) * 2"
+ - Modifiers: "2d6!", "4d6kh3", "5d6dl1", "3d6r1", "4d6ro1"
+
+ Returns:
+ A dictionary containing the following keys:
+ - expression: str - expression string
+ - total: int - Total points
+ - rolls: List[List[int]] - List of throw results
+ - details: str - details
+ - comment: str - User comment
+
+ Raises:
+ ValueError: When the expression is invalid
+
+ Example:
+ roller = OneRoll()
+ result = roller.roll("3d6 + 2")
+ print(f"Total points: {result['total']}")
+ """
+ ...
+
+ def roll_simple(self, dice_count: int, dice_sides: int) -> int:
+ """
+ Simple dice throw
+
+ Args:
+ dice_count: The number of dice must be greater than 0
+ dice_sides: The number of dice faces must be greater than 0
+
+ Returns:
+ Total points
+
+ Raises:
+ ValueError: When the parameter is invalid
+
+ Example:
+ roller = OneRoll()
+ total = roller.roll_simple(3, 6) # Throw 3 6-sided dice
+ """
+ ...
+
+ def roll_with_modifiers(
+ self,
+ dice_count: int,
+ dice_sides: int,
+ modifiers: ModifierList
+ ) -> DiceResult:
+ """
+ Dice throw with modifier
+
+ Args:
+ dice_count: The number of dice must be greater than 0
+ dice_sides: The number of dice faces must be greater than 0
+ modifiers: modifier list, supports the following formats:
+ - "!" - Explosion dice
+ - "r<number>" - Re-submit, such as "r1"
+ - "ro<number>" - Conditional re-submission, such as "ro1"
+ - "kh<number>" - Take the height, such as "kh3"
+ - "kl<number>" - Take the low, such as "kl2"
+ - "dh<number>" - discard the height, such as "dh1"
+ - "dl<number>" - discard low, such as "dl1"
+
+ Returns:
+ A dictionary containing the following keys:
+ - total: int - Total points
+ - rolls: List[List[int]] - List of throw results
+ - details: str - details
+
+ Raises:
+ ValueError: When the parameter or modifier is invalid
+
+ Example:
+ roller = OneRoll()
+ result = roller.roll_with_modifiers(4, 6, ["kh3"]) # 4d6kh3
+ print(f"Total points: {result['total']}")
+ """
+ ...
+
+def is_valid_expression(expression: str) -> bool:
+ """
+ Check if the expression is valid
+
+ Args:
+ expression: The expression string to check
+
+ Returns:
+ Return True if the expression is valid, otherwise return False
+
+ Example:
+ if is_valid_expression("3d6 + 2"):
+ result = roll_dice("3d6 + 2")
+ """
+ ...
+
+def parse_expression(expression: str) -> Dict[str, Any]:
+ """
+ Parses expressions but not throwing
+
+ Args:
+ expression: The expression string to parse
+
+ Returns:
+ Analytical results dictionary
+
+ Raises:
+ ValueError: When the expression is invalid
+ """
+ ...
+
+class RollStatistics:
+ """Throw statistics"""
+ min: int
+ max: int
+ mean: float
+ total: int
+ count: int
+ results: List[int]
+
+def roll_multiple(expression: str, times: int) -> RollHistory:
+ """
+ Throw the same expression multiple times
+
+ Args:
+ expression: dice expression string
+ times: The number of throws must be greater than 0
+
+ Returns:
+ Throw result list
+
+ Raises:
+ ValueError: When the parameter is invalid
+
+ Example:
+ results = roll_multiple("3d6", 10)
+ totals = [r["total"] for r in results]
+ """
+ ...
+
+def roll_statistics(expression: str, times: int) -> RollStatistics:
+ """
+ Statistics of multiple throws
+
+ Args:
+ expression: dice expression string
+ times: The number of throws must be greater than 0
+
+ Returns:
+ Object containing statistics
+
+ Raises:
+ ValueError: When the parameter is invalid
+
+ Example:
+ stats = roll_statistics("3d6", 100)
+ print(f"Average: {stats.mean:.2f}")
+ """
+ ...
+
+class CommonRolls:
+ """Commonly used dice expression constants"""
+ D20: str
+ D20_ADVANTAGE: str
+ D20_DISADVANTAGE: str
+ ATTRIBUTE_ROLL: str
+ D6_DAMAGE: str
+ D8_DAMAGE: str
+ D10_DAMAGE: str
+ D12_DAMAGE: str
+ HIT_POINTS_D6: str
+ HIT_POINTS_D8: str
+ HIT_POINTS_D10: str
+ HIT_POINTS_D12: str \ No newline at end of file
diff --git a/src/oneroll/grammar.pest b/src/oneroll/grammar.pest
new file mode 100644
index 0000000..01b97e9
--- /dev/null
+++ b/src/oneroll/grammar.pest
@@ -0,0 +1,43 @@
+WHITESPACE = _{ " " | "\t" | "\n" | "\r" }
+
+number = @{ "-"? ~ ("0" | ('1'..'9' ~ ('0'..'9')*)) }
+
+comment = { "#" ~ (!"\n" ~ ANY)* }
+
+dice_expr = { dice_term ~ (op ~ dice_term)* ~ comment? }
+dice_term = {
+ dice_roll
+ | paren_expr
+ | number
+}
+
+paren_expr = { "(" ~ dice_expr ~ ")" }
+
+dice_roll = {
+ number ~ "d" ~ dice_sides ~ modifiers?
+}
+
+dice_sides = @{ number }
+
+modifiers = { modifier+ }
+modifier = {
+ explode
+ | reroll
+ | reroll_once
+ | keep_high
+ | keep_low
+ | drop_high
+ | drop_low
+}
+
+explode = { "!" }
+reroll = { "r" ~ number }
+reroll_once = { "ro" ~ number }
+keep_high = { "kh" ~ number }
+keep_low = { "kl" ~ number }
+drop_high = { "dh" ~ number }
+drop_low = { "dl" ~ number }
+
+op = { "+" | "-" | "*" | "/" | "^" }
+
+main = { SOI ~ dice_expr ~ EOI } \ No newline at end of file
diff --git a/src/oneroll/tui.py b/src/oneroll/tui.py
new file mode 100644
index 0000000..9e4e4c9
--- /dev/null
+++ b/src/oneroll/tui.py
@@ -0,0 +1,251 @@
+#!/usr/bin/env python3
+"""
+OneRoll Terminal User Interface (TUI)
+
+An interactive dice roll interface created using textual.
+"""
+
+from textual.app import App, ComposeResult
+from textual.containers import Container, Horizontal, Vertical
+from textual.widgets import Header, Footer, Input, Button, Static, DataTable, Tabs, Tab
+from textual.reactive import reactive
+from textual.message import Message
+from typing import List, Dict, Any
+import json
+from datetime import datetime
+
+from . import OneRoll, roll, roll_statistics, CommonRolls
+
+class RollResult(Message):
+ """Throw result message"""
+
+ def __init__(self, result: Dict[str, Any], expression: str) -> None:
+ self.result = result
+ self.expression = expression
+ super().__init__()
+
+class ExpressionInput(Input):
+ """Expression input box"""
+
+ def __init__(self) -> None:
+ super().__init__(
+ placeholder="输入骰子表达式,如 3d6 + 2",
+ id="expression_input"
+ )
+
+ def on_key(self, event) -> None:
+ if event.key == "enter":
+ self.post_message(RollResult(roll(self.value), self.value))
+ self.value = ""
+
+class QuickRollButton(Button):
+ """Quick Throw Button"""
+
+ def __init__(self, label: str, expression: str) -> None:
+ self.expression = expression
+ super().__init__(label, id=f"quick_{expression}")
+
+ def on_button_pressed(self) -> None:
+ self.post_message(RollResult(roll(self.expression), self.expression))
+
+class RollHistory(DataTable):
+ """Throw History Table"""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.add_columns("时间", "表达式", "总点数", "详情")
+
+ def add_roll(self, result: Dict[str, Any], expression: str) -> None:
+ """Add throw record"""
+ time_str = datetime.now().strftime("%H:%M:%S")
+ self.add_row(
+ time_str,
+ expression,
+ str(result["total"]),
+ result["details"]
+ )
+ # keep table at bottom
+ self.scroll_end()
+
+class StatisticsPanel(Static):
+ """Statistics Panel"""
+
+ def __init__(self) -> None:
+ super().__init__("统计功能", id="stats_panel")
+
+ def show_statistics(self, expression: str, times: int = 100) -> None:
+ """Show statistics information"""
+ try:
+ stats = roll_statistics(expression, times)
+ stats_text = f"""
+统计结果: {expression} (投掷 {stats['count']} 次)
+
+最小值: {stats['min']}
+最大值: {stats['max']}
+平均值: {stats['mean']:.2f}
+总和: {stats['total']}
+ """
+ self.update(stats_text)
+ except Exception as e:
+ self.update(f"统计错误: {e}")
+
+class RollDisplay(Static):
+ """The throwing result shows """
+
+ def __init__(self) -> None:
+ super().__init__("等待投掷...", id="roll_display")
+
+ def show_result(self, result: Dict[str, Any], expression: str) -> None:
+ """Display throwing result"""
+ total = result["total"]
+ details = result["details"]
+ rolls = result["rolls"]
+
+ # a kind of color selection based on result
+ if total >= 15:
+ color = "green"
+ elif total >= 10:
+ color = "yellow"
+ else:
+ color = "red"
+
+ display_text = f"""[bold blue]🎲 {expression}[/bold blue]
+
+[bold]总点数:[/bold] [bold {color}]{total}[/bold {color}]
+[bold]详情:[/bold] {details}
+
+[bold]投掷结果:[/bold] {rolls}"""
+
+ # show the comment
+ comment = result.get("comment", "")
+ if comment:
+ display_text += f"\n\n[bold]注释:[/bold] [italic blue]{comment}[/italic blue]"
+
+ self.update(display_text)
+
+class OneRollTUI(App):
+ """OneRoll 终端用户界面"""
+
+ CSS = """
+ Screen {
+ layout: vertical;
+ }
+
+ #expression_input {
+ margin: 1;
+ }
+
+ #roll_display {
+ height: 8;
+ border: solid $primary;
+ margin: 1;
+ padding: 1;
+ }
+
+ #stats_panel {
+ height: 8;
+ border: solid $secondary;
+ margin: 1;
+ padding: 1;
+ }
+
+ #history_table {
+ height: 10;
+ margin: 1;
+ }
+
+ .quick_buttons {
+ height: 3;
+ margin: 1;
+ }
+
+ Button {
+ margin: 1;
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ """UI components"""
+ yield Header()
+
+ with Container():
+ yield ExpressionInput()
+
+ with Horizontal(classes="quick_buttons"):
+ yield QuickRollButton("D20", CommonRolls.D20)
+ yield QuickRollButton("优势", CommonRolls.D20_ADVANTAGE)
+ yield QuickRollButton("劣势", CommonRolls.D20_DISADVANTAGE)
+ yield QuickRollButton("属性", CommonRolls.ATTRIBUTE_ROLL)
+ yield QuickRollButton("3D6", "3d6")
+ yield QuickRollButton("2D6", "2d6")
+
+ yield RollDisplay()
+
+ with Tabs():
+ with Tab("历史记录", id="history_tab"):
+ yield RollHistory(id="history_table")
+
+ with Tab("统计", id="stats_tab"):
+ yield StatisticsPanel()
+
+ yield Footer()
+
+ def on_mount(self) -> None:
+ """Initialization during interface mount"""
+ self.title = "OneRoll 骰子投掷器"
+ self.sub_title = "高性能骰子表达式解析器"
+
+ # Set focus to the input box
+ self.query_one(ExpressionInput).focus()
+
+ def on_roll_result(self, message: RollResult) -> None:
+ # display result
+ roll_display = self.query_one(RollDisplay)
+ roll_display.show_result(message.result, message.expression)
+
+ # add history
+ history_table = self.query_one(RollHistory)
+ history_table.add_roll(message.result, message.expression)
+
+ def on_key(self, event) -> None:
+ if event.key == "ctrl+q":
+ self.exit()
+ elif event.key == "ctrl+h":
+ self.show_help()
+ elif event.key == "ctrl+s":
+ self.show_statistics()
+
+ def show_help(self) -> None:
+ help_text = """
+OneRoll 骰子投掷器
+
+支持的表达式格式:
+• 基本骰子: 3d6, 1d20, 2d10
+• 数学运算: 3d6 + 2, 2d6 * 3, (2d6 + 3) * 2
+• 修饰符:
+ - ! 爆炸骰子: 2d6!
+ - kh 取高: 4d6kh3
+ - kl 取低: 4d6kl2
+ - dh 丢弃高: 5d6dh1
+ - dl 丢弃低: 5d6dl1
+ - r 重投: 3d6r1
+ - ro 条件重投: 4d6ro1
+
+快捷键:
+• Ctrl+Q - 退出程序
+• Ctrl+H - 显示帮助
+• Ctrl+S - 显示统计
+• Enter - 执行投掷
+ """
+
+ self.notify(help_text, title="帮助信息", timeout=10)
+
+ def show_statistics(self) -> None:
+ self.notify("统计功能开发中...", title="统计")
+
+def run_tui():
+ app = OneRollTUI()
+ app.run()
+
+if __name__ == "__main__":
+ run_tui()