Skip to content

Instantly share code, notes, and snippets.

@sma
Last active October 23, 2021 10:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sma/060f910ffd3197d81a0a993d21d8b9bd to your computer and use it in GitHub Desktop.
Save sma/060f910ffd3197d81a0a993d21d8b9bd to your computer and use it in GitHub Desktop.
WIP dice formula parser

Dice Formula

Ich denke gerade über Würfelformeln für's Online-Spiel nach. Ein Ausdruck wie 2d20+3 ist universell: Würfle 2x W20, addiere die Ergebnisse, addiere 3.

Für viele Spiele braucht man aber komplexere Formeln.

Grundlagen

Allgemein kann ich Zahlen oder Würfel, die mit einem kleinen d gebildet werden, dem optional eine Anzahl vorangestellt und dem eine Seitenzahl folgen muss, mittels mathematischer Operationen verknüpfen und dabei auch Klammern benutzen, also z.B. (d6+1)/2. Um bei der Division keine Fließkommazahlen zu haben, definiere ich, dass (wie bei C) zur 0 hin gerundet wird.

Formal sieht das so aus:

expr = term {("+" | "-") term}.
term = factor {("*" | "/" | "%") factor}.
factor = "-" factor | value.
value = const | dice | parens.
const = num.
dice = [num] "d" num.
parens = "(" expr ")".

Komplexere Würfel

Ich möchte zusätzlich erlauben, dass Würfel nicht nur feste Anzahl und/oder Seiten haben, sondern hier in runden Klammern ein beliebiger Ausdruck stehen kann. Das macht die Grammatik nur minimal komplizierter und deutlich flexibler und für alle weiteren Betrachtungen können wir's wieder vergessen.

dice = [num | parens] "d" (num | parens).

Folgen

Ich möchte das Konzept einer Folge einführen: So etwas ist eine angeordenete (aber unsortiere) Menge an Ausdrücken, die ich mit Kommas getrennt in geschweiften Klammern schreibe:

value = const | dice | parens | series.
series = "{" [range {"," range} "}".
range = expr [".." expr].

Ich erlaube auch noch {1..4} als Kurzform für {1,2,3,4}. Dazu definere ich, dass wenn bei einem Bereich der Endwert größer als der Anfangswert ist, dieser zu nichts zusammenfällt. Man kann gemäß dieser Regeln übrigens auch Folgen von Folgen definieren, wo ich nicht sicher bin, was das bedeutet.

Ich möchte nun auch d{1,1,1,2,2,3} erlauben, was einen zufälligen Wert der Folge als Ergebnis des Würfelwurfs liefert. Damit ist d4 einfach nur die Kurzform für d{1..4}, was die Kurzform für d{1,2,3,4} ist.

dice = [num | parens] "d" (num | parens | series).

Später werde ich vom kleinsten oder größen Ergebnis eines Würfels sprechen. Dies ist der erste bzw. letzte Wert der Folge und nicht notwendigerweise der kleinste oder größte Wert der Ausdrücke in der Folge. Wer die Ausdrücke nicht aufsteigend sortiert hinschreibt, ist selbst schuld.

Werte

Konstanten, Würfel und alle zusammengesetzten Ausdrücke haben einen Wert. Für Konstanten ist das offensichtlich. Für Würfel ist dies das Würfelergebnis im Moment der Auswertung. Für geklammerte Ausdrücke ist das der Wert des Ausdrucks in Klammern. Und für alles andere ist es das Ergebnis der mathematischen Berechnung.

Auch Folgen haben einen Wert. Es ist die Summe alle Werte der Ausdrücke. Der Wert von {} sei 0.

Implementierung

Eine Implementierung in Dart

Als Werte kann ich nicht einfach nur Zahlen nehmen, sondern muss auch Folgen und Würfelwürfe mitführen, sonst kann ich das Ergebnis später nicht nachvollziehbar anzeigen. Da ich in Dart leider keine Union Types habe, definiere ich eine abstrakte Klasse Result, von der dann Zahlen (Number), Würfel (Die) und Folgen (Series) erben.

Jedes Result kennt sein total, was für Zahlen und Würfel einfach der intrinsische Wert ist, für Folgen aber rekursiv definiert ist und die Summe der totals der Werte der Folge ist.

abstract class Result {
  int get total;
}

class Number extends Result {
  Number(this.value);
  final int value;

  @override
  int get total => value;
}

class Die extends Result {
  Die(this.result, this.faces);
  final Result result;
  final Series faces;
  
  @override
  int get total => result.total;
}

class Series extends Result {
  Series(this.results);
  final List<Result> results;

  @override
  int get total => results.fold(0, (total, r) => total + r.total);
}

Eine Folge weiß auch noch, wie man einen zufälligen Wert bestimmt:

extension on Series {
  Result randomResult() => results[_random.nextInt(results.length)];
  
  static final _random = Random();
}

Nun kann ich Klassen definieren, die die Ausdrücke meiner Würfelformeln repräsentieren und alle eine eval-Methode, die dann die Formel berechnen.

abstract class DiceExpr {
  Result eval();
}

Für Konstanten ist dies trivial. Ich wandle den Wert in eine Num um.

class Const extends DiceExpr {
  Const(this.value);
  final int value;

  @override
  Result eval() => Number(value);
}

Für Mathematische Operationen muss ich auch nicht viel machen. Ich werte rekursiv die Ausdrücke aus, bestimme das total und wende die entsprechende Operation an, um dann wieder eine Num zu erzeugen.

class BinOp extends DiceExpr {
  BinOp(this.left, this.right, this.op);
  final DiceExpr left;
  final DiceExpr right;
  final int Function(int, int) op;

  Result eval() {
    return Number(op(left.eval().total, right.eval().total));
  }

  static BinOp add(DiceExpr left, DiceExpr right) {
    return BinOp(left, right, (l, r) => l + r);
  }

  static BinOp sub(DiceExpr left, DiceExpr right) {
    return BinOp(left, right, (l, r) => l - r);
  }

  // ...
}

Für Würfel berechne ich eine Folge für die durch die Seiten definierten möglichen Ergebnisse, berechne die Anzahl der Würfel, damit ich eine entsprechende Folge erzeugen kann und generiere dann entsprechend viele Würfelwürfe:

class Dice extends DiceExpr {
  Dice(this.count, this.faces);
  final DiceExpr count;
  final DiceExpr faces;

  Result eval() {
    final c = count.eval().total;
    if (c < 1) return Series([]);
    if (c > 1000) throw Error();
    final f = faces.eval();
    Series f1;
    if (f is Series) {
      f1 = f;
    } else {
      final h = f.total;
      if (h < 1 || h > 1000) throw Error();
      f1 = Series(List.generate(h, (i) => Number(i + 1)));
    }
    return Series(List.generate(c, (_) => Die(f1.randomResult(), f1)));
  }
}

Modifikatoren

Nun kann ich Folgen modifizieren.

Mit k oder kh für "keep highest" und einer Zahl N kann ich nur die höchsten N Werte einer Folge auswählen. Ist N größer als die Anzahl der Werte in der Folge, werden alle Werte ausgewählt. Ist N < 0, wird gar nichts ausgewählt. Fehlt das N, wird 1 angenommen. {d6,3}k wählt also das Ergebnis vom W6 oder die 3, was immer höher ist. Würden beide passen, wird von links nach rechts gewählt. Mit 4d6k3 werden die besten 3 von 4W6 ausgewählt. Man kan auch kl für "keep lowest" nutzen.

Sinnvoll ist dieser Modifikator hinter Folgen und Würfeln, schadet aber auch nicht hinter Ausdrücken in runden Klammern, wenn diese Folgen liefern und Konstanten, wenn man diese einfach als Folge mit diesem einen Element auffasst. Daher…

factor = "-" factor | primary {modifier}.
primary = parens | const | dice | series.
modifier = keep.
keep = ("k" | "kh" | "kl") (num | parens).

Mit cs (für "count successes") kann ich für jeden Wert einer Folge entscheiden, ob er als Erfolg oder Misserfolg zählt und somit den Wert der Folge von der Summe aller Werte auf die Anzahl der Erfolge ändern. Um wissen, was ein Erfolg ist, kann ein komplexer Filterausdruck angegeben werden, der folgender Grammatik genügt:

count = "cs" {filter}.
filter = "h" | "l" | num | op (num | parens) | "(" or-filter ")".
op = "=" | "<" | ">" | "<=" | ">=" | "<>".
or-filter = and-filter {"|" and-filter}.
and-filter = filter {"&" filter}.

Zunächst einmal kann cs mehr als einen Filter haben. Fehlt der Filter, gilt h, genau so wie es bei k ist. h steht für den höchsten möglichen Wert eines Würfels. l steht für den niedrigsten möglichen Wert. Konstanten werden ignoriert, Folgen rekursiv abgearbeitet. Eine Zahl N steht für =N, d.h. es wird nach genau diesem Wert gesucht. Die anderen Operatoren funktionierend entsprechend, d.h. suchen nach Werten, die entsprechend größer oder kleiner oder ungleich sind. Konstanten werden hierbei berücksichtigt. Andere Folgen rekursiv abgearbeitet und deren Erfolge dann addiert. In Klammern kann man auch "Oder" oder "Und" Bedingungen mehrere Filter formulieren. Die meisten Kombinationen machen in der Praxis keinen Sinn, aber (>5&<9) für einen Bereich von Zahlen könnte sinnvoll sein. Entscheidend ist, dass mehrere Filter nacheinander auf die selbe Folge angewendet werden und dabei alle Erfolge aufaddiert werden, d.h. Würfel können auch mehrfach gewertet werden. Bei 4d6cs>=4=6 zählt alles größer 3 als Erfolg und die 6 zählt doppelt. Das ist etwas anderes als 4d6cs(>=4|=6) oder 4d6cs(>=4&=6), was beides wenig sinnvoll wäre, weil im "Oder"-Fall die zweite Alternative egal ist und im "Und"-Fall die erste Alternative.

Mit cf (für "count failures") lässt sich alles negieren, d.h. nun sagen die Filter, was kein Erfolg ist. Foundry kann mit co und ce noch die ungeraden oder geraden Ergebnisse zählen, doch ich möchte das lieber als cso und cse analog zu csh und csl behandeln.

Mit sf (für "subtract failures") kann man entsprechend viele Filter-Ergebnisse von den Erfolgen anziehen und das Ergebnis einer Folge kann auf diese Weise auch negativ werden. Analog sollte es dann wohl auch ein ss (für "subtract successes") geben.

Zusammenfassend:

modifier = keep | count.
keep = ("k" | "kh" | "kl" | "ke" | "ko") (num | parens).
count = ("c" | "cs" | "cf" | "ss" | "sf") {filter}.
filter = "h" | "l" | "o" | "e" | num | op (num | parens) | "(" or-filter ")".

Folgen lassen sich sortieren. Dies hat auf das Ergebnis keinen Einfluss, aber wenn man die einzelnen Elemente anzeigen will, schon:

modifier = keep | count | sort.
sort = "s" | "sa" | "sd"
import 'dart:math';
/// A [DiceParser] parses a dice formula into an AST.
///
/// Throws [DiceParserException]s if the formula cannot be parsed.
///
/// The formula grammar is modelled after Foundry VTT. Currently support
/// for `{,}`, math functions, variables with `.`, dropping dice,
/// counting evens, odds or failures, deducting or subtracting failures,
/// margin of success is missing.
///
/// Grammar:
/// ```
/// expression = term {("+" | "-") term}.
/// term = factor {("*" | "/" | "%") factor}.
/// factor = "-" factor | parenthesized | constant | variable | dice | range.
/// parenthesized = "(" expression ")".
/// constant = NUMBER.
/// variable = "@" NAME.
/// dice = [count] "d" faces.
/// count = constant | parenthesized.
/// faces = constant | parenthesized.
/// series = "{" [range {"," range}] "}".
/// range = expression [".." expression].
/// modifier = reroll | explode | keep.
/// reroll = ("r" | "rr") [filter].
/// explode = ("x" | "xo") [filter].
/// keep = ("k" | "kh" | "kl") [filter].
/// filter = constant | filter-op count | "(" or-filter ")".
/// filter-op = "=" | "<" | ">" | "<=" | ">=" | "<>".
/// or-filter = and-filter {"|" and-filter}.
/// and-filter = filter {"&" filter}.
/// label = "[" {TEXT} "]".
/// ```
///
class DiceParser {
/// Constructs a new parser for [formula].
DiceParser(String formula) : _tokens = _tokenize(formula);
/// Returns whether we reached the end of the token stream.
bool get atEnd => _index == _tokens.length;
/// Returns whether the current token is a number.
/// If it does, the token is consumed. Otherwise, it is not.
/// Use [lastToken] to access the consumed token.
bool get atNumber => at(RegExp(r'\d+'));
/// Returns whether the current token conforms to [pattern].
/// If it does, the token is consumed. Otherwise, it is not.
/// Use [lastToken] to access the consumed token.
bool at(Pattern pattern) {
if (!atEnd && pattern.matchAsPrefix(_tokens[_index]) != null) {
_index += 1;
return true;
}
return false;
}
/// Returns the token that has just been consumed by [at] or [atNumber].
String get lastToken => _tokens[_index - 1];
/// Throws an exception that expresses [expectation].
Never expected(String expectation) {
final found = atEnd ? 'nothing' : _tokens[_index];
throw DiceParserException('expected $expectation but found "$found"');
}
/// Returns an AST constructed from the parser's formula.
AST parse() {
final expr = parseExpression();
if (!atEnd) expected('end');
return expr;
}
/// Parses `expression = term {("+" | "-") term}`.
AST parseExpression() {
var expr = parseTerm();
while (true) {
if (at('+')) {
expr = AST('+', expr, parseTerm());
} else if (at('-')) {
expr = AST('-', expr, parseTerm());
} else {
break;
}
}
return expr;
}
/// Parses `term = factor {("*" | "/" | "%") factor}`.
AST parseTerm() {
var expr = parseFactor();
while (true) {
if (at('*')) {
expr = AST('*', expr, parseFactor());
} else if (at('/')) {
expr = AST('/', expr, parseFactor());
} else if (at('%')) {
expr = AST('%', expr, parseFactor());
} else {
break;
}
}
return expr;
}
/// Parses `factor = "-" factor | parenthesized | constant | variable | dice | series`.
AST parseFactor() {
if (at('-')) {
return AST('neg', parseFactor());
}
final expr = parseConstant() ?? parseParenthesizedExpression();
if (at('d')) {
final count = expr ?? _one;
final faces = parseConstantOrParenthesizedExpression();
return parseModifiers(AST('dice', count, faces));
}
if (expr != null) {
return expr;
}
// variable
if (at(RegExp('@'))) {
final name = lastToken.substring(1);
return AST('var', name);
}
// series
if (at('{')) {
final exprs = <AST>[];
if (!at('}')) {
exprs.add(parseExpression());
while (at(',')) {
exprs.add(parseExpression());
}
if (!at('}')) {
expected('closing }');
}
}
return parseModifiers(AST('pool', exprs));
}
expected('constant, dice, or expression in parentheses');
}
AST parseModifiers(AST expr1) {
var expr = expr1;
while (true) {
if (at('r')) {
final filter = parseFilter() ?? AST('eql');
expr = AST('reroll-once', filter, expr);
} else if (at('rr')) {
final filter = parseFilter() ?? AST('eql');
expr = AST('reroll-repeatingly', filter, expr);
} else if (at('xo')) {
final filter = parseFilter() ?? AST('eqh');
expr = AST('explode-once', filter, expr);
} else if (at('x')) {
final filter = parseFilter() ?? AST('eqh');
expr = AST('explode-repeatingly', filter, expr);
} else if (at('c') || at('cc')) {
final filters = [parseFilter() ?? AST('eqh')];
while (true) {
final filter = parseFilter();
if (filter == null) break;
filters.add(filter);
}
expr = AST('count-successes', filters, expr);
} else if (at('s') || at('sa')) {
expr = AST('sort-ascending', expr);
} else if (at('sd')) {
expr = AST('sort-descending', expr);
} else if (at('k') || at('kh')) {
expr = AST('keep-highest', parseCount(), expr);
} else if (at('kl')) {
expr = AST('keep-lowest', parseCount(), expr);
} else if (at('dh')) {
expr = AST('drop-highest', parseCount(), expr);
} else if (at('dl')) {
expr = AST('drop-lowest', parseCount(), expr);
} else if (at('min')) {
final threshold = parseConstantOrParenthesizedExpression();
expr = AST('minimum', expr, threshold);
} else if (at('max')) {
final threshold = parseConstantOrParenthesizedExpression();
expr = AST('maximum', expr, threshold);
} else {
break;
}
}
// label
if (at(RegExp(r'\['))) {
final label = lastToken.substring(1, lastToken.length - 1);
expr = AST('labeled', label, expr);
}
return expr;
}
/// Parses `constant | parenthesized` and default to `1`.
AST parseCount() {
return parseConstant() ?? parseParenthesizedExpression() ?? _one;
}
/// Parses `filter = constant | filter-op count | "(" or-filter ")"`.
///
/// - Where `filter-op = "=" | "<" | ">" | "<=" | ">=" | "<>".
///
/// Returns an AST or `null` if there is neither a constant nor a filter
/// operator nor a parenthesized expression with more filter expressions.
AST? parseFilter() {
if (at('(')) {
final expr = parseOrFilter();
if (!at(')')) {
expected('closing )');
}
return expr;
} else if (at('=')) {
return AST('eq', parseConstantOrParenthesizedExpression());
} else if (at('<')) {
return AST('lt', parseConstantOrParenthesizedExpression());
} else if (at('>')) {
return AST('lt', parseConstantOrParenthesizedExpression());
} else if (at('<=')) {
return AST('le', parseConstantOrParenthesizedExpression());
} else if (at('>=')) {
return AST('ge', parseConstantOrParenthesizedExpression());
} else if (at('<>')) {
return AST('ne', parseConstantOrParenthesizedExpression());
} else {
return parseConstant();
}
}
/// Parses `or-filter = and-filter {"|" and-filter}`.
AST parseOrFilter() {
final filters = <AST>[parseAndFilter()];
while (at('|')) {
filters.add(parseAndFilter());
}
return AST('or', filters);
}
/// Parses `and-filter = filter {"&" filter}`.
AST parseAndFilter() {
AST parseFilterOrFail() {
final filter = parseFilter();
if (filter == null) {
expected('filter');
}
return filter;
}
final filters = <AST>[parseFilterOrFail()];
while (at('&')) {
filters.add(parseFilterOrFail());
}
return AST('and', filters);
}
/// Parses `constant | parenthesized`.
///
/// Returns a `const` AST node if the token stream contains a number.
/// Otherwise, returns an AST expression node if the token stream contains
/// an expression in parentheses. Otherwise, throws a syntax error.
AST parseConstantOrParenthesizedExpression() {
final expr = parseConstant() ?? parseParenthesizedExpression();
if (expr != null) {
return expr;
}
expected('constant or expression in parentheses');
}
/// Parses `constant = NUMBER`.
///
/// Returns a `const` AST node if the token stream contains a number.
/// Otherwise, returns `null` if it contains anything else or nothing at all.
AST? parseConstant() {
return atNumber ? AST('const', int.parse(lastToken)) : null;
}
/// Parses `parenthesized = "(" expression ")"`.
///
/// Returns an AST expression node if the token stream contains an
/// expression in parentheses. Otherwise, returns `null` if it contains
/// anything else or nothing at all.
AST? parseParenthesizedExpression() {
if (at('(')) {
final expr = parseExpression();
if (!at(')')) {
expected('closing )');
}
return expr;
}
return null;
}
final List<String> _tokens;
var _index = 0;
/// Splits [formula] into tokens.
/// Invalid characters become
static List<String> _tokenize(String formula) {
// ignore_for_file: unnecessary_raw_strings
return RegExp(
r'\d+|' // constant, i.e. 42
r'd|' // dice, i.e. 3d6
r'@\w+|' // variable, i.e. @answer
r'[-+*/%]|' // math operators
r'[(){,}|&]|' // syntax
r'rr?|' // reroll (recursively)
r'xo?|' // explode (once)
r'cs?|' // count successes
r's[ad]?|' // sorting (ascending*/descending)
r'k[hl]?|' // keep (highest*/lowest)
r'min|max|' // minimum or maximum
r'=|<[>=]?|>=?|' // filter operators
r'\[.*?\]|' // dice label
r'.()', // error fallback
).allMatches(formula).map((m) => m[1] ?? m[0]!).toList();
}
static final _one = AST('const', 1);
static final _six = AST('const', 6);
}
/// Represents a [DiceParser] syntax error.
class DiceParserException implements Exception {
DiceParserException(this.message);
final String message;
@override
String toString() => 'Syntax Error: $message';
}
// starting here is random temporary code
class AST {
AST(this.type, [Object? a1, Object? a2, Object? a3]) //
: arguments = <Object?>[a1, a2, a3].whereType<Object>().toList();
final String type;
final List<Object> arguments;
@override
String toString() => '$type[${arguments.join(', ')}]';
}
final _random = Random();
int eval(AST ast) {
switch (ast.type) {
case 'const':
return ast.arguments[0] as int;
case 'dice':
{
final count = eval(ast.arguments[0] as AST);
final faces = eval(ast.arguments[1] as AST);
return Iterable.generate(count, (_) => _random.nextInt(faces) + 1).fold(0, (sum, die) => sum + die);
}
case '+':
{
final left = eval(ast.arguments[0] as AST);
final right = eval(ast.arguments[1] as AST);
return left + right;
}
default:
throw Exception('please implement $ast');
}
}
int roll(String formula) {
return eval(DiceParser(formula).parse());
}
void main() {
print(DiceParser('{(3d6+1d7r<5cs(1|6)<4[wild card]-3)*d(d20/2),d13}k').parse());
print(roll('3d6+1'));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment