Exact probability distributions for dice expressions — a Python port of the Troll dice language
Project description
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.
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
pip install -e .
# Optional: faster exact arithmetic and array acceleration
pip install -e ".[fast]" # adds numpy + cfractions
Quick Start
Rolling Dice
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
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:
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:
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:
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
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
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:
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
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:
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():
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
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
# 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
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
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, the full Troll language is supported:
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
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
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
from troll.dice import d, pool
# Dice pool with exploding 10s
pool(7, d(10).explode(on=10)).where_gt(7).count().dist()
Shadowrun
from troll.dice import d, pool
# Count hits (5+) on a dice pool
pool(12, d(6)).where_ge(5).count().dist()
Risk
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
from troll.dice import d, pool
# 4dF: four dice showing -1, 0, or +1
pool(4, d(3) - 2).sum().dist()
Savage Worlds
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:
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:
# monsters.yaml
goblin:
hp: "sum (2 # d6) + 2"
attack: "(d20 + 4)"
damage: "(d6 + 2)"
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
make test # run all 436 tests
make lint # ruff
make typecheck # pyright
make check # all three
make test-no-numpy # verify fallback paths
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file troll_dice-0.1.0.tar.gz.
File metadata
- Download URL: troll_dice-0.1.0.tar.gz
- Upload date:
- Size: 56.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
be7a23b42fd4da31ac5204e92df10757f1dd41803666ac6def986ecd53025dc0
|
|
| MD5 |
9d6591376674d4c1eb5070df26f1fa95
|
|
| BLAKE2b-256 |
7a3da5d59ed12fd6f477691a4bc4f44036df97998c717ecb8360b961b8007b7b
|
File details
Details for the file troll_dice-0.1.0-py3-none-any.whl.
File metadata
- Download URL: troll_dice-0.1.0-py3-none-any.whl
- Upload date:
- Size: 38.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
08a62fe74ff3c6a7578df21af88b7b8d43cb3d7fd5f8818891a273381662c310
|
|
| MD5 |
bd205f52bbb55166a8ba84e621028f4c
|
|
| BLAKE2b-256 |
c4d5042e520a6b38254c3c7b755989a329bb8d3c60e5b7ef0a62fd45a2030c62
|