Metadata-Version: 2.4
Name: troll-dice
Version: 0.1.0
Summary: Exact probability distributions for dice expressions — a Python port of the Troll dice language
Author: Matt Anderson
License-Expression: MIT
Keywords: dice,probability,rpg,troll,distribution,tabletop,dnd
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Games/Entertainment :: Role-Playing
Classifier: Topic :: Scientific/Engineering :: Mathematics
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: click>=8
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: ruff; extra == "dev"
Requires-Dist: pyright; extra == "dev"
Requires-Dist: hypothesis; extra == "dev"
Provides-Extra: fast
Requires-Dist: numpy; extra == "fast"
Requires-Dist: cfractions; extra == "fast"
Provides-Extra: stats
Requires-Dist: scipy; extra == "stats"
Provides-Extra: all
Requires-Dist: numpy; extra == "all"
Requires-Dist: scipy; extra == "all"
Requires-Dist: cfractions; extra == "all"
Dynamic: license-file

# troll

A Python library for dice probability analysis. Compute exact probability distributions for any dice expression — from simple `2d6+3` to exploding dice pools, contested rolls, and beyond.

Two ways to use it: a **Python API** with method chaining, or the **Troll DSL** for compact dice notation. Both produce the same results using the same engine.

```python
from troll.dice import d, pool

# D&D stat roll: best 3 of 4d6
stat = pool(4, d(6)).highest(3).sum()
stat.roll()        # → 13
stat.dist().mean   # → 12.24

# Or use the Troll DSL
import troll
troll.dist("sum largest 3 4d6").mean  # → 12.24
```

## Install

```bash
pip install -e .

# Optional: faster exact arithmetic and array acceleration
pip install -e ".[fast]"  # adds numpy + cfractions
```

## Quick Start

### Rolling Dice

```python
from troll.dice import d, pool

d(20).roll()                          # → 14
(d(20) + 5).roll()                    # → 19
pool(3, d(6)).roll()                  # → [2, 4, 5]
pool(2, d(6)).sum().roll()            # → 8
pool(4, d(6)).highest(3).sum().roll() # → 13
```

`.roll()` returns `int` for single-number results and `list[int]` for collections.

### Probability Distributions

```python
from troll.dice import d, pool

dist = pool(2, d(6)).sum().dist()

dist.mean    # 7.0
dist.std     # 2.415...

# Probability of rolling 10 or higher
float(dist.prob(lambda v: int(v) >= 10))  # 0.1667

# Full distribution
for value, prob in dist:
    print(f"  {int(value):>2}: {float(prob)*100:.2f}%")
```

Distributions use exact rational arithmetic by default — no floating-point error. Pass `exact=False` for faster computation when precision isn't critical.

### The Two APIs

**Python API** — build expressions with objects and method chains:

```python
from troll.dice import d, pool, let, foreach, range_, if_, coin

pool(4, d(6)).highest(3).sum()        # best 3 of 4d6, summed
pool(2, d(20)).max() + 5              # advantage + modifier
pool(8, d(6)).where_ge(5).count()     # count 5+ on 8d6
d(10).explode(on=10).sum()            # exploding d10
```

**Troll DSL** — compact string notation:

```python
import troll

troll.dist("sum largest 3 4d6")
troll.dist("max 2d20 + 5")
troll.dist("count 5<= 8d6")
troll.dist("sum accumulate x:=d10 while x=10")
```

**Going between them:**

```python
from troll.dice import d, pool, from_str

# Python → string (for serialization)
expr = pool(4, d(6)).highest(3).sum()
repr(expr)  # "sum largest 3 (4 # d6)"

# String → Python (for deserialization)
expr = from_str("sum largest 3 4d6")
expr.roll()  # → 13
```

`repr()` produces valid Troll syntax. `from_str()` parses it back into a Python `Expr`. Round-trip is lossless.

## The Python API

### Dice

```python
d(6)              # d6: faces 1-6
d(20)             # d20: faces 1-20
z(6)              # z6: faces 0-6
coin(0.5)         # 1 with 50% probability, nothing otherwise
```

### Pools

```python
pool(4, d(6))     # roll 4d6 → collection of 4 values
4 * d(6)          # same thing
pool(3, d(8))     # 3d8
```

### Aggregation

All return a single-number expression:

```python
pool(3, d(6)).sum()       # total: 3-18
pool(3, d(6)).count()     # always 3
pool(3, d(6)).max()       # highest die
pool(3, d(6)).min()       # lowest die
pool(3, d(6)).highest(2)  # keep 2 highest → collection
pool(3, d(6)).lowest(1)   # keep 1 lowest → collection
pool(3, d(6)).median()    # middle value
```

### Arithmetic

```python
d(20) + 5           # add modifier
d(8) * 2            # double the roll
pool(2, d(6)).sum() + 3   # 2d6+3
d(100) // 10        # divide (integer)
d(6) % 2            # odd or even
-d(6)               # negate
```

Note: `expr * int` is arithmetic multiplication. `int * expr` is repetition (pool). Use `pool()` when you want repetition explicitly.

### Filtering

Troll's comparisons are filters — they keep elements from a collection that pass a test:

```python
pool(5, d(6)).where_gt(3)   # keep dice showing > 3
pool(5, d(6)).where_eq(6)   # keep only 6s
pool(5, d(6)).where_ge(5)   # keep 5s and 6s
pool(5, d(6)).where_lt(3)   # keep dice showing < 3
pool(5, d(6)).where_le(2)   # keep 1s and 2s
pool(5, d(6)).where_ne(1)   # drop 1s
```

Count successes by chaining `.count()`:

```python
pool(8, d(6)).where_ge(5).count()     # Shadowrun: count 5+ on 8d6
pool(5, d(10)).where_gt(7).count()    # WoD: count 8+ on 5d10
```

### Collection Operations

```python
d(6) | d(8)                 # union: combine two dice into one pool
collection(1, 2, 3)         # literal collection
range_(1, 6)                # {1, 2, 3, 4, 5, 6}
range_(1, 6).choose()       # pick one uniformly at random
pool(5, d(6)).unique()      # remove duplicate values
pool(3, d(6)).drop(1)       # remove all 1s
pool(3, d(6)).keep(5, 6)    # keep only 5s and 6s
range_(1, 10).pick(3)       # randomly pick 3 without replacement
a.diff(b)                   # multiset subtraction
```

### Die Modifiers

```python
# Exploding die (reroll and accumulate on a value)
d(10).explode(on=10)                          # WoD exploding d10
d(6).explode(while_=lambda x: x.where_ge(5)) # explode on 5+

# Reroll (discard and reroll on a value)
d(6).reroll(on=6)          # d5 by rerolling 6s
d(6).reroll(on=1)          # reroll 1s, keep result
```

### Control Flow

```python
from troll.dice import let, foreach, range_, if_, coin

# Let binding: roll once, use the result multiple times
let('x', d(6), lambda x: x + x)   # roll d6, double it

# Conditional
if_(coin(0.5), d(6), d(8))        # flip a coin: d6 or d8

# Foreach: iterate over a collection
foreach('i', range_(1, 6), lambda i:
    pool(3, d(6)).where_eq(i).count()
)  # count how many 3d6 match each face
```

### Pairs

```python
from troll.dice import pair

p = pair(d(4), d(6))
p.first()    # the d4 result
p.second()   # the d6 result
```

## The Troll DSL

For users familiar with [Troll](http://www.diku.dk/~torbenm/Troll/), the full Troll language is supported:

```python
import troll

# Parse and evaluate
troll.dist("sum 2d6").mean
troll.roll("sum 2d6")

# Or parse separately for reuse
prog = troll.parse("sum largest 3 4d6")
prog.sample()          # one random roll
prog.distribution()    # exact distribution
```

### Troll Syntax Quick Reference

| Troll                            | Meaning                          |
|----------------------------------|----------------------------------|
| `d6`, `3d6`                      | Die roll, repeated roll          |
| `sum 3d6`                        | Total of 3d6                     |
| `count 3d6`                      | Number of dice (always 3)        |
| `largest 3 4d6`                  | Keep 3 highest of 4d6            |
| `least 2 4d6`                    | Keep 2 lowest of 4d6             |
| `max 3d6`, `min 3d6`             | Single highest/lowest            |
| `choose {1,2,3}`                 | Pick one at random               |
| `different 3d6`                  | Remove duplicates                |
| `3 < d6`                         | Filter: keep elements > 3        |
| `3 = 2d6`                        | Filter: keep elements equal to 3 |
| `x := d6 ; x + x`                | Bind a roll, reuse it            |
| `if d6 then 1 else 0`            | Conditional                      |
| `foreach i in 1..6 do ...`       | Iterate                          |
| `repeat x := d6 while x=6`       | Reroll while condition holds     |
| `accumulate x := d10 while x=10` | Exploding die                    |
| `function f(x) = x+1`            | User-defined function            |
| `call f(d6)`                     | Function call                    |

### CLI

```bash
troll 'sum 2d6'                    # probability distribution
troll -s 'sum 2d6'                 # single roll
troll -n 10 'sum 2d6'              # 10 rolls
troll --parse-only 'sum 2d6'       # show parsed syntax
troll Troll/Examples/Risk.t        # evaluate a .t file
```

## RPG Examples

### D&D 5e

```python
from troll.dice import d, pool, let, if_

# Ability score: best 3 of 4d6
pool(4, d(6)).highest(3).sum().dist().mean  # 12.24

# Attack with advantage
(pool(2, d(20)).max() + 7).dist()

# Damage with critical
damage = pool(2, d(8)).sum() + 5
crit = pool(4, d(8)).sum() + 5
let('hit', d(20) + 7, lambda hit:
    if_(hit.where_ge(20), crit, damage)
).dist()

# Great Weapon Fighting: reroll 1s and 2s on damage dice
pool(2, d(6).reroll(while_=lambda x: x.where_le(2))).sum() + 5
```

### World of Darkness

```python
from troll.dice import d, pool

# Dice pool with exploding 10s
pool(7, d(10).explode(on=10)).where_gt(7).count().dist()
```

### Shadowrun

```python
from troll.dice import d, pool

# Count hits (5+) on a dice pool
pool(12, d(6)).where_ge(5).count().dist()
```

### Risk

```python
from troll.dice import d, pool, let

let('a', pool(3, d(6)), lambda a:
    let('b', pool(2, d(6)), lambda b:
        a.max().where_gt(b.max()).count() +
        a.highest(2).min().where_gt(b.min()).count()
    )
).dist()
```

### FATE / Fudge

```python
from troll.dice import d, pool

# 4dF: four dice showing -1, 0, or +1
pool(4, d(3) - 2).sum().dist()
```

### Savage Worlds

```python
from troll.dice import d

# Trait die + wild die, keep highest (both explode)
trait = d(8).explode(on=8)
wild = d(6).explode(on=6)
(trait | wild).max().sum().dist()
```

## Serialization

Dice expressions serialize to Troll syntax strings and back:

```python
from troll.dice import d, pool, from_str

# Build → serialize
expr = pool(4, d(6)).highest(3).sum()
data = repr(expr)   # "sum largest 3 (4 # d6)"

# Deserialize → use
expr = from_str(data)
expr.roll()         # → 13
expr.dist().mean    # → 12.24
```

Use this for storing dice expressions in YAML, JSON, databases, or config files:

```yaml
# monsters.yaml
goblin:
  hp: "sum (2 # d6) + 2"
  attack: "(d20 + 4)"
  damage: "(d6 + 2)"
```

```python
import yaml
from troll.dice import from_str

monsters = yaml.safe_load(open("monsters.yaml"))
hp = from_str(monsters["goblin"]["hp"])
hp.roll()   # → 9
hp.dist()   # exact HP distribution
```

## Performance

With numpy installed, most expressions complete in under 1ms:

| Expression                | Time   |
|---------------------------|--------|
| `d20 + 5`                 | 0.03ms |
| `sum 2d6 + 3`             | 0.03ms |
| D&D stat roll             | 0.5ms  |
| `sum 100d6`               | 0.8ms  |
| WoD 5-die exploding pool  | 1.2ms  |
| Shadowrun 12d6 count hits | 0.03ms |

Without numpy, the same expressions are 2-25x slower but still fast for interactive use. Without cfractions, exact arithmetic is ~3x slower.

## Development

```bash
make test          # run all 436 tests
make lint          # ruff
make typecheck     # pyright
make check         # all three
make test-no-numpy # verify fallback paths
```
