binkley / kotlin-dice

A dice expression evaluator in Kotlin
The Unlicense
0 stars 0 forks source link

<img src="./images/public-domain.svg" alt="Public Domain" align="right" width="20%" height="auto"/>

Kotlin Dice Parser

build pull requests issues vulnerabilities license

A complete dice expression has these parts:

The smallest dice expression is just a die type, eg, d6 meaning roll a single, regular 6-sided die. See Dice Expression Syntax and Examples, below, for more interesting expressions.

Try ./roll --demo for a demonstration, or ./roll --demo --verbose to see more in how dice expressions work. Running ./roll presents an interactive prompt for entering and evaluating dice expressions.

Table of contents

Build

Note — CI presently consistently fails owing to troubles with command line interations for features like tab completion.

Use ./mvnw (Maven) or ./batect build (Batect) to build, run tests, and create a demo program. Use ./roll or ./batect demo to run the demo.

CI uses Batect to verify builds and behavior, so an easy way for you to check your changes before pushing to GitHub.

Command line

Try ./roll --help to see this help on the command line:

Usage:
roll [-hrvV] [--copyright] [--demo] [--no-history] [-C[=WHEN]] [-m=MINIMUM]
     [-P=PROMPT] [-s=SEED] [--] [@<filename>...] [EXPRESSION(s)...] [COMMAND]

Description:
Roll dice expressions.

Parameters:
      [@<filename>...]     One or more argument files containing options.
      [EXPRESSION(s)...]   Dice expressions to roll.

Options:
  -C, --color[=WHEN]       Choose color output (always, yes, force, auto, tty,
                             if-tty, never, no, none).
                           Default with no option is 'auto'.
                           Default with option but no WHEN is 'always'.
      --copyright          Show the copyright and exit.
      --demo               Run the demo and exit.
  -h, --help               Show this help message and exit.
  -m, --minimum=MINIMUM    Fail roll results below MINIMUM.
                           Default with no option is no minimum.
      --no-history         Do not save history from the REPL.
  -P, --prompt=PROMPT      Change the REPL prompt from '🎲 '.
  -r, --result-only        Show only roll results.
  -s, --seed=SEED          Fix RNG seed to SEED for repeatable roll results.
  -v, --verbose            Show die rolls as they happens.
  -V, --version            Print version information and exit.
  --                       This option can be used to separate command-line
                             options from the list of positional parameters.

Commands:
  clear    clear the screen
  history  list command history excluding this command
  options  view or change options

Input modes:
  roll
     Run the REPL.
  roll <expression(s)>
     Show roll results of dice expression(s) and exit.
  echo <expression(s)> | roll
     Show roll result of dice expression(s) read from STDIN and exit.

Output examples:
  roll --seed=1 2d4 2d4 (normal)
     2d4 4
     2d4 7
  roll --seed=1 --verbose 2d4 2d4 (verbose)
     ---
     roll(d4) -> 1
     roll(d4) -> 3
     2d4 -> 4
     ---
     roll(d4) -> 4
     roll(d4) -> 3
     2d4 -> 7

Files:
  ~/.roll_history
     This file preserves input history across runs of the REPL.

Error messages:
  Incomplete dice expression '<EXPRESSION>'
     More characters were expected at the end of EXPRESSION.
  Unexpected '<CHAR>' (at position <POS>) in dice expression '<EXPRESSION>'
     CHAR was not expected in EXPRESSION at position POS (starting from 1).
  Result <ROLL> is below the minimum result of <NUMBER>
     ROLL is too low for the NUMBER in the --minimum option.
  Exploding on <NUMBER> will never finish in dice expression '<EXPRESSION>'
     NUMBER is too low for the number of sides on the die.
  History disabled because of the --no-history option
     Read a history command ('!' first character) but option set for no history.

Exit codes:
    0   Successful completion
    1   Bad dice expression
    2   Bad program usage
  130   REPL interrupted (SIGINT)

Dice expression syntax

Parsing dice expressions turns out to be an interesting programming problem. This project implements a mashup of several dice expression syntaxes, drawing inspiration from:

This project supports these types of expressions:

[N]'B'D['r'R]['h'[K]|'m'[K]|'n'[K]|'l'[K]][!|!Z]['x'M|'*'M][+EXP|-EXP...][+A|-A]

For example, in D&D a d20 roll with advantage is "2d20h" and with disadvantage is "2d20l", and in Star Wars an exploding d6 roll is "d6!".

All characters are case-insensitive, eg, d6 and D6 are the same expression.

Whitespace is supported only:

Notes:

See TODO for further improvements.

Examples

The demo examples (look at demoExpressions) cover all supported examples.

REPL

Running ./roll with arguments or input starts an interactive REPL (read-evaluate-print loop). The REPL includes many features, courtesy of Picocli and JLine3, including:

In a terminal, output is colorized. In a README.md it looks like:

$ ./roll
🎲 3d6
3d6 8
$ ./roll --verbose
🎲 3d6
---
roll(d6) -> 4
roll(d6) -> 2
roll(d6) -> 1
3d6 -> 7
🎲

API

The code falls into two halves:

Main

Library

The key method is dice(random, reporter) in the companion object of DiceParser. This creates a reuseable parser and roller.

The random parameter is a Kotlin Random, and defaults to the system RNG. The reporter parameter is a RollReporter and defaults to "do nothing" (ie, no reporting).

The simplest example is:

val dice = dice() // Static import
val result = dice.roll("3d6")
println(result.resultValue)

A fancier example might be:

val dice = dice(Random(1)) { rolledDice ->
  with(rolledDice) {
    val die = when (dieBase) {
      ONE -> "d$dieSides"
      ZERO -> "z$dieSides"
    }
    val trace = when (this) {
      is PlainRoll -> "rolled $die was $roll"
      is PlainReroll -> "rerolled $die was $roll"
      is ExplodedRoll -> "exploded $die >= $explodeHigh was $roll"
      is ExplodedReroll -> "exploded reroll $die >= $explodeHigh is $roll"
      is DroppedRoll -> "dropped $die was $roll"
    }
    println(trace)
  }
}
val result = dice.roll("2d20h")
// Above tracing prints here
println("result is ${result.resultValue}")

And would output:

rolled d20 was 6
rolled d20 was 17
dropped d20 was 6
result is 17

Code conventions

At each top-level part of a dice expression parse (eg, die sides), the parser saves a local value internally. By the end of the dice expression, this includes:

The parser uses a stack for some cases:

Goals for execution script roll and main():

Multiple modes of operation:

Remember to distinguish STDOUT and STDERR, helpful when using ./roll in scripts.

Key dependencies

Much gratitude to the authors of these libraries:

TODO

References