‘Faster Than Light: Nomad’ Probabilities 2

Posted: 2024-08-07
Last Modified: 2024-10-21
Word Count: 872
Tags: ftl-nomad python-code rpg

Table of Contents

(This article refers to Faster Than Light: Nomad by Stellagama Publishing.)

Just showing off a new version of the Python script that I use to generate the probability tables for Nomad. It includes command line arguments to change parameters at runtime, and a much faster recursive algorithm to calculate probabilities. Same probabilities though, just with more dice in something less than several minutes. (Or hours … or days …)

The Table

./rollkeep.py -b 5

TN Skill -5D -4D -3D -2D -1D +0D +1D +2D +3D +4D +5D
2 6 100.00 100.00 100.00 100.00 100.00 100.00 100.00 100.00 100.00 100.00 100.00
3 5 66.98 73.68 80.38 86.81 92.59 97.22 99.54 99.92 99.99 100.00 100.00
4 4 38.15 46.66 56.65 67.98 80.09 91.67 98.15 99.61 99.92 99.98 100.00
5 3 17.92 25.07 34.84 47.84 64.35 83.33 94.91 98.46 99.52 99.85 99.95
6 2 7.84 12.32 19.41 30.56 47.69 72.22 89.35 95.99 98.50 99.44 99.79
7 1 2.77 5.11 9.43 17.36 31.94 58.33 80.56 90.97 95.78 98.01 99.06
8 0 0.94 1.99 4.22 9.03 19.44 41.67 68.06 82.64 90.57 94.89 97.23
9 0.21 0.56 1.50 4.01 10.65 27.78 52.31 69.44 80.59 87.68 92.16
10 0.05 0.15 0.48 1.54 5.09 16.67 35.65 52.16 65.16 74.93 82.08
11 0.00 0.02 0.08 0.39 1.85 8.33 19.91 32.02 43.35 53.34 61.85
12 0.00 0.00 0.01 0.08 0.46 2.78 7.41 13.19 19.62 26.32 33.02

Usage

usage: rollkeep.py [-h] [-k KEEP] [-b BONUS] [-d SIDES] [-t TARGET] [-n]

Generate a probability table for roll-and-keep mechanics.

options:
  -h, --help            show this help message and exit
  -k KEEP, --keep KEEP  number of dice to keep (default 2)
  -b BONUS, --bonus BONUS
                        max number of bonus/penalty dice (default 3)
  -d SIDES, --sides SIDES
                        number of sides (default 6)
  -t TARGET, --target TARGET
                        standard target number (default 8)
  -n, --negative-skill  show negative skill numbers

rollkeep.py

#!/usr/bin/env python3

import argparse

from functools import cache
from fractions import Fraction
from itertools import combinations_with_replacement, chain, product

import tabulate

NKEEP: int = 2
NBONUS: int = 3
NSIDES: int = 6
TARGET: int = 8

SetHistogram = dict[tuple[int, ...], Fraction]

Histogram = dict[int, Fraction]

Cell = Fraction | int | None

Table = list[list[Cell]]


def new_tuple(t: tuple[int, ...], d: int, low: bool) -> tuple[int, ...]:
    temp_t = tuple(sorted((d,) + t))
    if low:
        return temp_t[:-1]
    return temp_t[1:]


@cache
def set_histogram(ndice: int, nsides: int, nkeep: int, low: bool) -> SetHistogram:
    assert nkeep <= ndice
    result: SetHistogram = {}

    for t in combinations_with_replacement(range(1, nsides + 1), nkeep):
        result[t] = Fraction(0, 1)

    if nkeep == ndice:
        # Create simple set histogram
        p: Fraction = Fraction(1, nsides**nkeep)
        for t in product(range(1, nsides + 1), repeat=nkeep):
            modt: tuple[int, ...] = tuple(sorted(t))
            result[modt] += p

        return result

    # Multiply lesser order set_histogram by one die
    lesser: SetHistogram = set_histogram(ndice - 1, nsides, nkeep, low)
    onedie_p: Fraction = Fraction(1, nsides)
    for d, t in product(range(1, nsides + 1), lesser):
        new_t: tuple[int, ...] = new_tuple(t, d, low)
        result[new_t] += lesser[t] * onedie_p

    return result


def histogram(ndice: int, nsides: int, nkeep: int, low: bool) -> Histogram:
    result: Histogram = {}
    sethist: SetHistogram = set_histogram(ndice, nsides, nkeep, low)

    # Sum the sets to provide a Histogram
    for n in range(nkeep, nsides * nkeep + 1):
        result[n] = Fraction(0, 1)
    for t in sethist:
        sum_n: int = sum(t)
        result[sum_n] += sethist[t]
    return result


def cumulative(h: Histogram) -> Histogram:
    result: Histogram = {}
    counter: Fraction = Fraction(0, 1)
    for key in reversed(sorted(list(h))):
        counter += h[key]
        result[key] = counter
    return result


def format_pct(h: dict, i: int) -> Cell:
    if i not in h:
        return None
    return h[i] * 100


def skill(k: int, target: int, neg: bool = False) -> Cell:
    if neg:
        return target - k
    return target - k if k <= target else None


def collate(hl: list[Histogram], target: int = TARGET, neg: bool = False) -> Table:
    allkeys: list[int] = sorted(set(chain.from_iterable([list(h) for h in hl])))

    return [
        [k, skill(k, target, neg)] + [format_pct(h, k) for h in hl] for k in allkeys
    ]


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Generate a probability table for roll-and-keep mechanics."
    )
    parser.add_argument(
        "-k",
        "--keep",
        help=f"number of dice to keep (default {NKEEP})",
        default=NKEEP,
        type=int,
    )
    parser.add_argument(
        "-b",
        "--bonus",
        help=f"max number of bonus/penalty dice (default {NBONUS})",
        default=NBONUS,
        type=int,
    )
    parser.add_argument(
        "-d",
        "--sides",
        help=f"number of sides (default {NSIDES})",
        default=NSIDES,
        type=int,
    )
    parser.add_argument(
        "-t",
        "--target",
        help=f"standard target number (default {TARGET})",
        default=TARGET,
        type=int,
    )
    parser.add_argument(
        "-n",
        "--negative-skill",
        help="show negative skill numbers",
        action="store_true",
    )
    args = parser.parse_args()

    keep: int = args.keep
    bonus: int = args.bonus
    sides: int = args.sides

    headers = ["TN", "Skill"] + [f"{n:+}D" for n in range(-bonus, bonus + 1)]

    table = collate(
        [
            cumulative(histogram(ntotal, sides, nkeep=keep, low=True))
            for ntotal in range(keep + bonus, keep, -1)
        ]
        + [
            cumulative(histogram(ntotal, sides, nkeep=keep, low=False))
            for ntotal in range(keep, keep + bonus + 1)
        ],
        target=args.target,
        neg=args.negative_skill,
    )

    print(tabulate.tabulate(table, headers=headers, tablefmt="pipe", floatfmt=".2f"))


if __name__ == "__main__":
    main()

Postscript (2024-09-11)

If anyone wants to check my math, simply copy and paste this script into AnyDice.

NDICE:  2
NSIDES: 6
NBONUS: 5

output (NDICE)d(NSIDES) named "[NDICE]D[NSIDES]"

loop BONUS over {1 .. NBONUS} {
    output [lowest NDICE of (NDICE+BONUS)d(NSIDES)] named "[NDICE]D[NSIDES] - [BONUS]b"
}

loop BONUS over {1 .. NBONUS} {
    output [highest NDICE of (NDICE+BONUS)d(NSIDES)] named "[NDICE]D[NSIDES] + [BONUS]b"
}

Be sure to select “At Least”, since I’m using cumulative probabilities above.