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.
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 ")".
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).
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.
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.
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 total
s 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)));
}
}
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"