Skip to main content

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

troll_dice-0.1.0.tar.gz (56.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

troll_dice-0.1.0-py3-none-any.whl (38.5 kB view details)

Uploaded Python 3

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

Hashes for troll_dice-0.1.0.tar.gz
Algorithm Hash digest
SHA256 be7a23b42fd4da31ac5204e92df10757f1dd41803666ac6def986ecd53025dc0
MD5 9d6591376674d4c1eb5070df26f1fa95
BLAKE2b-256 7a3da5d59ed12fd6f477691a4bc4f44036df97998c717ecb8360b961b8007b7b

See more details on using hashes here.

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

Hashes for troll_dice-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 08a62fe74ff3c6a7578df21af88b7b8d43cb3d7fd5f8818891a273381662c310
MD5 bd205f52bbb55166a8ba84e621028f4c
BLAKE2b-256 c4d5042e520a6b38254c3c7b755989a329bb8d3c60e5b7ef0a62fd45a2030c62

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page