From cc26e2272f0eb0997dac9295d40d461b366437ce Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 28 Jun 2025 13:35:41 +0200 Subject: [PATCH 1/3] Future and custom dice rolling support --- example/main.dart | 2 +- example/simple.dart | 8 +- lib/src/ast.dart | 328 ++++++++++++++++++++------------ lib/src/dice_expression.dart | 20 +- lib/src/dice_roller.dart | 56 +++--- test/dart_dice_parser_test.dart | 72 ++++--- 6 files changed, 302 insertions(+), 184 deletions(-) diff --git a/example/main.dart b/example/main.dart index 46b52fc..6cb243d 100644 --- a/example/main.dart +++ b/example/main.dart @@ -112,7 +112,7 @@ void main(List arguments) async { if (collectStats) { random = Random(); } - final diceExpr = DiceExpression.create(input, random); + final diceExpr = DiceExpression.create(input, diceRoller: DefaultDiceRoller(random)); exit( await run( diff --git a/example/simple.dart b/example/simple.dart index 5b0107a..14a5574 100644 --- a/example/simple.dart +++ b/example/simple.dart @@ -22,11 +22,11 @@ Future main() async { // // The following example uses a seeded RNG so that results are the same on every run (so that the asserts below won't fail) // - final d20adv = DiceExpression.create('4d20 kh2 #cf #cs', Random(4321)); + final d20adv = DiceExpression.create('4d20 kh2 #cf #cs', diceRoller: DefaultDiceRoller(Random(4321))); // repeated rolls of the dice expression generate different results - final result1 = d20adv.roll(); - final result2 = d20adv.roll(); + final result1 = await d20adv.roll(); + final result2 = await d20adv.roll(); stdout.writeln(result1); stdout.writeln(result2); @@ -100,7 +100,7 @@ Future main() async { ), ); - final stats = await DiceExpression.create('2d6', Random(1234)).stats(); + final stats = await DiceExpression.create('2d6', diceRoller: DefaultDiceRoller(Random(1234))).stats(); // output: // {mean: 6.99, stddev: 2.4, min: 2, max: 12, count: 1000, histogram: {2: 27, 3: 56, 4: 90, 5: 98, 6: 138, 7: 180, 8: 141, 9: 109, 10: 80, 11: 51, 12: 30}} stdout.writeln(stats); diff --git a/lib/src/ast.dart b/lib/src/ast.dart index 75260d9..e626c14 100644 --- a/lib/src/ast.dart +++ b/lib/src/ast.dart @@ -23,7 +23,7 @@ class SimpleValue extends DiceExpression { final RollResult _results; @override - RollResult call() => _results; + Future call() async => _results; @override String toString() => value; @@ -34,11 +34,11 @@ class SimpleValue extends DiceExpression { /// The `eval()` method is called from the node abstract class DiceOp extends DiceExpression with LoggingMixin { // each child class should override this to implement their operation - RollResult eval(); + Future eval(); // all children can share this call operator -- and it'll let us be consistent w/ regard to logging @override - RollResult call() { + Future call() { final result = eval(); logger.finer(() => '$result'); return result; @@ -73,7 +73,10 @@ class MultiplyOp extends Binary { MultiplyOp(super.name, super.left, super.right); @override - RollResult eval() => left() * right(); + Future eval() async { + final results = await Future.wait([left(), right()]); + return results[0] * results[1]; + } } /// add operation @@ -81,7 +84,10 @@ class AddOp extends Binary { AddOp(super.name, super.left, super.right); @override - RollResult eval() => left() + right(); + Future eval() async { + final results = await Future.wait([left(), right()]); + return results[0] + results[1]; + } } /// subtraction operation @@ -89,7 +95,10 @@ class SubOp extends Binary { SubOp(super.name, super.left, super.right); @override - RollResult eval() => left() - right(); + Future eval() async { + final results = await Future.wait([left(), right()]); + return results[0] - results[1]; + } } /// variation on count -- count how many results from lhs are =,<,> rhs. @@ -116,12 +125,15 @@ class CountOp extends Binary { CountType countType; @override - RollResult eval() { + Future eval() async { final lhs = left(); final rhs = right(); + final leftRight = await Future.wait([lhs, rhs]); + final finalLeft = leftRight[0]; + final finalRight = leftRight[1]; var rhsEmptyAndSimpleCount = false; - final target = rhs.totalOrDefault( + final target = finalRight.totalOrDefault( () { // if missing RHS, we can make assumptions depending on operator. // @@ -132,7 +144,7 @@ class CountOp extends Binary { return 0; case '#s' || '#cs': // example: '3d6#s' -- assume target is nsides (maximum) - return lhs.nsides; + return finalLeft.nsides; case '#f' || '#cf': // example: '3d6#f' -- assume target is 1 (minimum) return 1; @@ -181,7 +193,7 @@ class CountOp extends Binary { } } - final filteredResults = lhs.results.where(test); + final filteredResults = finalLeft.results.where(test); if (countType == CountType.count) { // if counting, the count becomes the new result @@ -190,28 +202,28 @@ class CountOp extends Binary { expression: toString(), opType: OpType.count, metadata: RollMetadata( - discarded: lhs.results, + discarded: finalLeft.results, ), results: [filteredResults.length], - ndice: lhs.ndice, - nsides: lhs.nsides, - left: lhs, - right: rhs, + ndice: finalLeft.ndice, + nsides: finalLeft.nsides, + left: finalLeft, + right: finalRight, ); } else { // if counting success/failures, the results are unchanged return RollResult( expression: toString(), - results: lhs.results, + results: finalLeft.results, opType: OpType.count, metadata: RollMetadata( score: RollScore.forCountType(countType, List.of(filteredResults)), ), - ndice: lhs.ndice, - nsides: lhs.nsides, - left: lhs, - right: rhs, + ndice: finalLeft.ndice, + nsides: finalLeft.nsides, + left: finalLeft, + right: finalRight, ); } } @@ -222,11 +234,15 @@ class DropOp extends Binary { DropOp(super.name, super.left, super.right); @override - RollResult eval() { + Future eval() async { final lhs = left(); final rhs = right(); - final target = rhs.totalOrDefault(() { + final leftRight = await Future.wait([lhs, rhs]); + final finalLeft = leftRight[0]; + final finalRight = leftRight[1]; + + final target = finalRight.totalOrDefault(() { throw FormatException( 'Invalid drop operation. Missing drop target', toString(), @@ -238,20 +254,20 @@ class DropOp extends Binary { var dropped = []; switch (name) { case '-<': // drop < - results = lhs.results.where((v) => v >= target).toList(); - dropped = lhs.results.where((v) => v < target).toList(); + results = finalLeft.results.where((v) => v >= target).toList(); + dropped = finalLeft.results.where((v) => v < target).toList(); case '-<=': // drop <= - results = lhs.results.where((v) => v > target).toList(); - dropped = lhs.results.where((v) => v <= target).toList(); + results = finalLeft.results.where((v) => v > target).toList(); + dropped = finalLeft.results.where((v) => v <= target).toList(); case '->': // drop > - results = lhs.results.where((v) => v <= target).toList(); - dropped = lhs.results.where((v) => v > target).toList(); + results = finalLeft.results.where((v) => v <= target).toList(); + dropped = finalLeft.results.where((v) => v > target).toList(); case '->=': // drop >= - results = lhs.results.where((v) => v < target).toList(); - dropped = lhs.results.where((v) => v >= target).toList(); + results = finalLeft.results.where((v) => v < target).toList(); + dropped = finalLeft.results.where((v) => v >= target).toList(); case '-=': // drop = - results = lhs.results.where((v) => v != target).toList(); - dropped = lhs.results.where((v) => v == target).toList(); + results = finalLeft.results.where((v) => v != target).toList(); + dropped = finalLeft.results.where((v) => v == target).toList(); default: throw FormatException( "unknown drop operation '$name'", @@ -263,14 +279,14 @@ class DropOp extends Binary { return RollResult( expression: toString(), opType: OpType.drop, - ndice: lhs.ndice, - nsides: lhs.nsides, + ndice: finalLeft.ndice, + nsides: finalLeft.nsides, results: results, metadata: RollMetadata( discarded: dropped, ), - left: lhs, - right: rhs, + left: finalLeft, + right: finalRight, ); } } @@ -280,11 +296,17 @@ class DropHighLowOp extends Binary { DropHighLowOp(super.name, super.left, super.right); @override - RollResult eval() { + Future eval() async { final lhs = left(); final rhs = right(); - final sorted = lhs.results..sort(); - final numToDrop = rhs.totalOrDefault(() => 1); // if missing, assume '1' + + final leftRight = await Future.wait([lhs, rhs]); + final finalLeft = leftRight[0]; + final finalRight = leftRight[1]; + + final sorted = finalLeft.results..sort(); + final numToDrop = + finalRight.totalOrDefault(() => 1); // if missing, assume '1' var results = []; var dropped = []; switch (name) { @@ -313,14 +335,14 @@ class DropHighLowOp extends Binary { return RollResult( expression: toString(), opType: OpType.drop, - ndice: lhs.ndice, - nsides: lhs.nsides, + ndice: finalLeft.ndice, + nsides: finalLeft.nsides, results: results, metadata: RollMetadata( discarded: dropped, ), - left: lhs, - right: rhs, + left: finalLeft, + right: finalRight, ); } } @@ -330,10 +352,15 @@ class ClampOp extends Binary { ClampOp(super.name, super.left, super.right); @override - RollResult eval() { + Future eval() async { final lhs = left(); final rhs = right(); - final target = rhs.totalOrDefault(() { + + final leftRight = await Future.wait([lhs, rhs]); + final finalLeft = leftRight[0]; + final finalRight = leftRight[1]; + + final target = finalRight.totalOrDefault(() { throw FormatException( 'Invalid clamp operation. Missing clamp target', toString(), @@ -346,7 +373,7 @@ class ClampOp extends Binary { final added = []; switch (name) { case 'c>': // change any value > rhs to rhs - results = lhs.results.map((v) { + results = finalLeft.results.map((v) { if (v > target) { discarded.add(v); added.add(target); @@ -356,7 +383,7 @@ class ClampOp extends Binary { } }).toList(); case 'c<': // change any value < rhs to rhs - results = lhs.results.map((v) { + results = finalLeft.results.map((v) { if (v < target) { discarded.add(v); added.add(target); @@ -375,15 +402,15 @@ class ClampOp extends Binary { return RollResult( expression: toString(), opType: OpType.clamp, - ndice: lhs.ndice, - nsides: lhs.nsides, + ndice: finalLeft.ndice, + nsides: finalLeft.nsides, results: results, metadata: RollMetadata( discarded: discarded, rolled: added, ), - left: lhs, - right: rhs, + left: finalLeft, + right: finalRight, ); } } @@ -410,8 +437,8 @@ class FudgeDice extends UnaryDice { FudgeDice(super.name, super.left, super.roller); @override - RollResult eval() { - final lhs = left(); + Future eval() async { + final lhs = await left(); final ndice = lhs.totalOrDefault(() => 1); // redundant w/ RangeError checks in the DiceRoller. But we can construct better error messages here. @@ -422,7 +449,7 @@ class FudgeDice extends UnaryDice { left.toString().length, ); } - final roll = roller.rollFudge(ndice); + final roll = await roller.rollFudge(ndice); return RollResult.fromRollResult( roll, expression: toString(), @@ -444,11 +471,12 @@ class CSVDice extends UnaryDice { String toString() => '(${left}d${vals.elements})'; @override - RollResult eval() { - final lhs = left(); + Future eval() async { + final lhs = await left(); final ndice = lhs.totalOrDefault(() => 1); - final roll = roller.rollVals(ndice, vals.elements.map(int.parse).toList()); + final roll = + await roller.rollVals(ndice, vals.elements.map(int.parse).toList()); return RollResult.fromRollResult( roll, @@ -464,11 +492,11 @@ class PercentDice extends UnaryDice { PercentDice(super.name, super.left, super.roller); @override - RollResult eval() { - final lhs = left(); + Future eval() async { + final lhs = await left(); const nsides = 100; final ndice = lhs.totalOrDefault(() => 1); - final roll = roller.roll(ndice, nsides); + final roll = await roller.roll(ndice, nsides); return RollResult.fromRollResult( roll, expression: toString(), @@ -486,13 +514,18 @@ class D66Dice extends UnaryDice { D66Dice(super.name, super.left, super.roller); @override - RollResult eval() { - final lhs = left(); + Future eval() async { + final lhs = await left(); final ndice = lhs.totalOrDefault(() => 1); - final results = [ - for (var i = 0; i < ndice; i++) - roller.roll(1, 6).results.sum * 10 + roller.roll(1, 6).results.sum, - ]; + // Roll all dice at once, then compute D66 values. + final tensRolls = + await Future.wait(List.generate(ndice, (_) => roller.roll(1, 6))); + final onesRolls = + await Future.wait(List.generate(ndice, (_) => roller.roll(1, 6))); + final results = List.generate( + ndice, + (i) => tensRolls[i].results.sum * 10 + onesRolls[i].results.sum, + ); return RollResult( expression: toString(), opType: OpType.rollD66, @@ -514,11 +547,16 @@ class StdDice extends BinaryDice { String toString() => '($left$name$right)'; @override - RollResult eval() { + Future eval() async { final lhs = left(); final rhs = right(); - final ndice = lhs.totalOrDefault(() => 1); - final nsides = rhs.totalOrDefault(() => 1); + + final leftRight = await Future.wait([lhs, rhs]); + final finalLeft = leftRight[0]; + final finalRight = leftRight[1]; + + final ndice = finalLeft.totalOrDefault(() => 1); + final nsides = finalRight.totalOrDefault(() => 1); // redundant w/ RangeError checks in the DiceRoller. But we can construct better error messages here. if (ndice < DiceRoller.minDice || ndice > DiceRoller.maxDice) { @@ -535,7 +573,7 @@ class StdDice extends BinaryDice { left.toString().length + name.length + 1, ); } - final roll = roller.roll(ndice, nsides); + final roll = await roller.roll(ndice, nsides); return RollResult.fromRollResult( roll, expression: toString(), @@ -543,8 +581,8 @@ class StdDice extends BinaryDice { metadata: RollMetadata( rolled: roll.results, ), - left: lhs, - right: rhs, + left: finalLeft, + right: finalRight, ); } } @@ -565,18 +603,22 @@ class RerollDice extends BinaryDice { int limit; @override - RollResult eval() { + Future eval() async { final lhs = left(); final rhs = right(); - if (lhs.nsides == 0) { + final leftRight = await Future.wait([lhs, rhs]); + final finalLeft = leftRight[0]; + final finalRight = leftRight[1]; + + if (finalLeft.nsides == 0) { throw FormatException( "Invalid reroll operation. Cannot determine # sides from '$left'", toString(), left.toString().length, ); } - final target = rhs.totalOrDefault(() { + final target = finalRight.totalOrDefault(() { throw FormatException( 'Invalid reroll operation. Missing reroll target', toString(), @@ -608,37 +650,57 @@ class RerollDice extends BinaryDice { } } - lhs.results.forEachIndexed((i, v) { + // Prepare a list of futures for all rerolls that need to be performed. + final rerollFutures = >[]; + final rerollIndices = []; + + for (var i = 0; i < finalLeft.results.length; i++) { + final v = finalLeft.results[i]; if (test(v)) { - int rerolled; - var rerollCount = 0; - do { - rerolled = roller - .roll(1, lhs.nsides, '(reroll ind $i, #$rerollCount)') + // Schedule reroll for this index. + rerollIndices.add(i); + // Chain rerolls up to the limit. + Future rerollFuture(int rerollCount, int lastValue) async { + if (rerollCount >= limit || !test(lastValue)) return lastValue; + final rerolled = (await roller.roll( + 1, finalLeft.nsides, '(reroll ind $i, #$rerollCount)')) .results .sum; - rerollCount++; - } while (test(rerolled) && rerollCount < limit); + return rerollFuture(rerollCount + 1, rerolled); + } + + rerollFutures.add(rerollFuture(0, v)); + } + } + + // Await all rerolls in parallel. + final rerolledValues = await Future.wait(rerollFutures); + + int rerollIdx = 0; + for (var i = 0; i < finalLeft.results.length; i++) { + final v = finalLeft.results[i]; + if (test(v)) { + final rerolled = rerolledValues[rerollIdx++]; results.add(rerolled); discarded.add(v); added.add(rerolled); } else { results.add(v); } - }); + } return RollResult( expression: toString(), opType: OpType.reroll, - ndice: lhs.ndice, - nsides: lhs.nsides, + ndice: finalLeft.ndice, + nsides: finalLeft.nsides, results: results, metadata: RollMetadata( rolled: added, discarded: discarded, ), - left: lhs, - right: rhs, + left: finalLeft, + right: finalRight, ); } } @@ -659,18 +721,22 @@ class CompoundingDice extends BinaryDice { int limit; @override - RollResult eval() { + Future eval() async { final lhs = left(); final rhs = right(); - if (lhs.nsides == 0) { + final leftRight = await Future.wait([lhs, rhs]); + final finalLeft = leftRight[0]; + final finalRight = leftRight[1]; + + if (finalLeft.nsides == 0) { throw FormatException( "Invalid compounding operation. Cannot determine # sides from '$left'", toString(), left.toString().length, ); } - final target = rhs.totalOrDefault(() => lhs.nsides); + final target = finalRight.totalOrDefault(() => finalLeft.nsides); bool test(int val) { switch (name) { case '!!' || '!!=' || '!!o' || '!!o=': @@ -695,39 +761,57 @@ class CompoundingDice extends BinaryDice { final results = []; final discarded = []; final added = []; - lhs.results.forEachIndexed((i, v) { + // Prepare a list of compound roll futures for all dice that need compounding. + final compoundFutures = >[]; + final compoundIndices = []; + + for (var i = 0; i < finalLeft.results.length; i++) { + final v = finalLeft.results[i]; if (test(v)) { - var sum = v; - int rerolled; - var numCompounded = 0; - do { - rerolled = roller - .roll(1, lhs.nsides, '(compound ind $i, #$numCompounded)') + compoundIndices.add(i); + // Chain compounding rolls up to the limit. + Future compoundFuture( + int compoundCount, int sum, int lastValue) async { + if (compoundCount >= limit || !test(lastValue)) return sum; + final rolled = (await roller.roll( + 1, finalLeft.nsides, '(compound ind $i, #$compoundCount)')) .results .sum; - sum += rerolled; - numCompounded++; - } while (test(rerolled) && numCompounded < limit); - results.add(sum); + return compoundFuture(compoundCount + 1, sum + rolled, rolled); + } + + compoundFutures.add(compoundFuture(0, v, v)); + } + } + + // Await all compounding rolls in parallel. + final compoundedValues = await Future.wait(compoundFutures); + + int compoundIdx = 0; + for (var i = 0; i < finalLeft.results.length; i++) { + final v = finalLeft.results[i]; + if (test(v)) { + final compounded = compoundedValues[compoundIdx++]; + results.add(compounded); discarded.add(v); - added.add(sum); + added.add(compounded); } else { results.add(v); } - }); + } return RollResult( expression: toString(), opType: OpType.compound, - ndice: lhs.ndice, - nsides: lhs.nsides, + ndice: finalLeft.ndice, + nsides: finalLeft.nsides, results: results, metadata: RollMetadata( rolled: added, discarded: discarded, ), - left: lhs, - right: rhs, + left: finalLeft, + right: finalRight, ); } } @@ -748,18 +832,22 @@ class ExplodingDice extends BinaryDice { int limit; @override - RollResult eval() { + Future eval() async { final lhs = left(); final rhs = right(); - if (lhs.nsides == 0) { + final leftRight = await Future.wait([lhs, rhs]); + final finalLeft = leftRight[0]; + final finalRight = leftRight[1]; + + if (finalLeft.nsides == 0) { throw FormatException( "Invalid exploding operation. Cannot determine # sides from '$left'", toString(), left.toString().length, ); } - final target = rhs.totalOrDefault(() => lhs.nsides); + final target = finalRight.totalOrDefault(() => finalLeft.nsides); final allResults = []; final newResults = []; @@ -785,15 +873,17 @@ class ExplodingDice extends BinaryDice { } } - allResults.addAll(lhs.results); - var numToRoll = lhs.results.where(test).length; + allResults.addAll(finalLeft.results); + var numToRoll = finalLeft.results.where(test).length; var explodeCount = 0; while (numToRoll > 0 && explodeCount < limit) { - final results = roller.roll( + // Roll all dice for this explosion round in parallel + final rollFuture = roller.roll( numToRoll, - lhs.nsides, + finalLeft.nsides, '(explode #${explodeCount + 1})', ); + final results = await rollFuture; newResults.addAll(results.results); numToRoll = results.results.where(test).length; explodeCount++; @@ -803,12 +893,12 @@ class ExplodingDice extends BinaryDice { return RollResult( expression: toString(), opType: OpType.explode, - ndice: lhs.ndice, - nsides: lhs.nsides, + ndice: finalLeft.ndice, + nsides: finalLeft.nsides, results: allResults, metadata: RollMetadata(rolled: newResults), - left: lhs, - right: rhs, + left: finalLeft, + right: finalRight, ); } } diff --git a/lib/src/dice_expression.dart b/lib/src/dice_expression.dart index b5c4ebc..264e56d 100644 --- a/lib/src/dice_expression.dart +++ b/lib/src/dice_expression.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:logging/logging.dart'; import 'package:petitparser/petitparser.dart'; @@ -59,10 +57,10 @@ abstract class DiceExpression { /// /// Throws [FormatException] if invalid static DiceExpression create( - String input, [ - Random? random, - ]) { - final builder = parserBuilder(DiceRoller(random)); + String input, { + DiceRoller? diceRoller, + }) { + final builder = parserBuilder(diceRoller ?? DefaultDiceRoller()); final result = builder.parse(input); if (result is Failure) { throw FormatException( @@ -75,16 +73,16 @@ abstract class DiceExpression { } /// each DiceExpression operation is callable (when we call the parsed string, this is the method that'll be used) - RollResult call(); + Future call(); /// Rolls the dice expression /// /// Throws [FormatException] - RollSummary roll({ + Future roll({ Function(RollResult rollResult) onRoll = noopListener, Function(RollSummary rollSummary) onSummary = noopSummaryListener, - }) { - final rollResult = this(); + }) async { + final rollResult = await this(); callListeners(rollResult, onRoll: onRoll); @@ -101,7 +99,7 @@ abstract class DiceExpression { /// Throws [FormatException] Stream rollN(int num) async* { for (var i = 0; i < num; i++) { - yield roll(); + yield await roll(); } } diff --git a/lib/src/dice_roller.dart b/lib/src/dice_roller.dart index 3c975c8..1d5f8d4 100644 --- a/lib/src/dice_roller.dart +++ b/lib/src/dice_roller.dart @@ -3,14 +3,8 @@ import 'dart:math'; import 'results.dart'; import 'utils.dart'; -/// A dice roller for M dice of N sides (e.g. `2d6`). -/// A roll returns a list of ints. -class DiceRoller with LoggingMixin { - /// Constructs a dice roller - DiceRoller([Random? r]) : _random = r ?? Random.secure(); - - final Random _random; - +/// Abstract dice roller interface. +abstract class DiceRoller with LoggingMixin { /// minimum dice to roll (0) static const int minDice = 0; @@ -27,10 +21,31 @@ class DiceRoller with LoggingMixin { static const int defaultExplodeLimit = 100; /// Roll ndice of nsides and return results as list. - RollResult roll(int ndice, int nsides, [String msg = '']) { - RangeError.checkValueInInterval(ndice, minDice, maxDice, 'ndice'); - RangeError.checkValueInInterval(nsides, minSides, maxSides, 'nsides'); - // nextInt is zero-inclusive; add 1 so result will be in range 1-nsides + Future roll(int ndice, int nsides, [String msg = '']); + + /// Roll N fudge dice, return results + Future rollFudge(int ndice); + + /// Roll N dice with custom side values, return results + Future rollVals(int ndice, List sideVals); +} + +/// Default implementation of DiceRoller. +class DefaultDiceRoller extends DiceRoller { + /// Constructs a dice roller + DefaultDiceRoller([Random? r]) : _random = r ?? Random.secure(); + + final Random _random; + + /// select n items from the list of values + List selectN(int n, List vals) => [ + for (var i = 0; i < n; i++) vals[_random.nextInt(vals.length)], + ]; + + @override + Future roll(int ndice, int nsides, [String msg = '']) async { + RangeError.checkValueInInterval(ndice, DiceRoller.minDice, DiceRoller.maxDice, 'ndice'); + RangeError.checkValueInInterval(nsides, DiceRoller.minSides, DiceRoller.maxSides, 'nsides'); final results = [ for (int i = 0; i < ndice; i++) _random.nextInt(nsides) + 1, ]; @@ -47,14 +62,9 @@ class DiceRoller with LoggingMixin { static const _fudgeVals = [-1, -1, 0, 0, 1, 1]; - /// select n items from the list of values - List selectN(int n, List vals) => [ - for (var i = 0; i < n; i++) vals[_random.nextInt(vals.length)], - ]; - - /// Roll N fudge dice, return results - RollResult rollFudge(int ndice) { - RangeError.checkValueInInterval(ndice, minDice, maxDice, 'ndice'); + @override + Future rollFudge(int ndice) async { + RangeError.checkValueInInterval(ndice, DiceRoller.minDice, DiceRoller.maxDice, 'ndice'); final results = selectN(ndice, _fudgeVals); logger.finest(() => 'roll ${ndice}dF => $results'); @@ -68,9 +78,9 @@ class DiceRoller with LoggingMixin { ); } - /// Roll N fudge dice, return results - RollResult rollVals(int ndice, List sideVals) { - RangeError.checkValueInInterval(ndice, minDice, maxDice, 'ndice'); + @override + Future rollVals(int ndice, List sideVals) async { + RangeError.checkValueInInterval(ndice, DiceRoller.minDice, DiceRoller.maxDice, 'ndice'); final results = selectN(ndice, sideVals); logger.finest(() => 'roll ${ndice}d$sideVals => $results'); diff --git a/test/dart_dice_parser_test.dart b/test/dart_dice_parser_test.dart index ab65891..162abb5 100644 --- a/test/dart_dice_parser_test.dart +++ b/test/dart_dice_parser_test.dart @@ -22,9 +22,12 @@ void main() { ).thenReturn(1); }); void staticRandTest(String name, String input, int expectedTotal) { - test('$name - $input', () { + test('$name - $input', () async { expect( - DiceExpression.create(input, staticMockRandom).roll().total, + (await DiceExpression.create(input, + diceRoller: DefaultDiceRoller(staticMockRandom)) + .roll()) + .total, equals(expectedTotal), ); }); @@ -41,8 +44,10 @@ void main() { int? critSuccessCount, int? critFailureCount, }) { - test('$testName - $inputExpr', () { - final rollSummary = DiceExpression.create(inputExpr, seededRandom).roll(); + test('$testName - $inputExpr', () async { + final rollSummary = await DiceExpression.create(inputExpr, + diceRoller: DefaultDiceRoller(staticMockRandom)) + .roll(); if (expectedTotal != null) { expect( rollSummary.total, @@ -390,7 +395,9 @@ void main() { test('missing clamp target', () { expect( - () => DiceExpression.create('6d6 C<', seededRandom).roll(), + () => DiceExpression.create('6d6 C<', + diceRoller: DefaultDiceRoller(seededRandom)) + .roll(), throwsFormatException, ); }); @@ -398,7 +405,8 @@ void main() { group('listeners', () { test('basic', () { - final dice = DiceExpression.create('2d6 kh', seededRandom); + final dice = DiceExpression.create('2d6 kh', + diceRoller: DefaultDiceRoller(seededRandom)); final results = []; final summaries = []; dice.roll( @@ -474,7 +482,9 @@ void main() { test('missing nsides', () { expect( - () => DiceExpression.create('6d', seededRandom).roll(), + () => DiceExpression.create('6d', + diceRoller: DefaultDiceRoller(seededRandom)) + .roll(), throwsFormatException, ); }); @@ -635,10 +645,11 @@ void main() { ); seededRandTest('fudge add to d6', '4dF+4d6', 13); - test('multiple rolls is multiple results', () { - final dice = DiceExpression.create('2d6', seededRandom); - expect(dice.roll().total, 8); - expect(dice.roll().total, 6); + test('multiple rolls is multiple results', () async { + final dice = DiceExpression.create('2d6', + diceRoller: DefaultDiceRoller(seededRandom)); + expect((await dice.roll()).total, 8); + expect((await dice.roll()).total, 6); }); test('create dice with real random', () { @@ -649,13 +660,16 @@ void main() { }); test('string method returns expr', () { - final dice = DiceExpression.create('2d6# + 5d6!>=5 + 5D66', seededRandom); + final dice = DiceExpression.create('2d6# + 5d6!>=5 + 5D66', + diceRoller: DefaultDiceRoller(seededRandom)); expect(dice.toString(), '((2d6) # (( + ((5d6) !>= 5)) + (5D66)))'); }); test('invalid dice str', () { expect( - () => DiceExpression.create('1d5 + x2', seededRandom).roll(), + () => DiceExpression.create('1d5 + x2', + diceRoller: DefaultDiceRoller(seededRandom)) + .roll(), throwsFormatException, ); }); @@ -671,7 +685,9 @@ void main() { for (final i in invalids) { test('invalid - $i', () { expect( - () => DiceExpression.create(i, seededRandom).roll(), + () => DiceExpression.create(i, + diceRoller: DefaultDiceRoller(seededRandom)) + .roll(), throwsFormatException, ); }); @@ -681,7 +697,7 @@ void main() { // mocked responses should return rolls of 6, 2, 1, 5 final dice = DiceExpression.create( '(4d(3+3)! + (2+2)d6) #cs #cf #s #f', - seededRandom, + diceRoller: DefaultDiceRoller(seededRandom), ); final out = dice.roll().toString(); expect( @@ -691,13 +707,13 @@ void main() { ), ); }); - test('toStringPretty', () { + test('toStringPretty', () async { // mocked responses should return rolls of 6, 2, 1, 5 final dice = DiceExpression.create( '(4d(3+3)! + (2+2)d6) #cs #cf #s #f', - seededRandom, + diceRoller: DefaultDiceRoller(seededRandom), ); - final out = dice.roll().toStringPretty(); + final out = (await dice.roll()).toStringPretty(); expect( out, equals( @@ -718,10 +734,11 @@ void main() { ), ); }); - test('toJson', () { + test('toJson', () async { // mocked responses should return rolls of 6, 2, 1, 5 - final dice = DiceExpression.create('4d6', seededRandom); - final obj = dice.roll().toJson(); + final dice = DiceExpression.create('4d6', + diceRoller: DefaultDiceRoller(seededRandom)); + final obj = (await dice.roll()).toJson(); expect( obj, equals({ @@ -745,10 +762,11 @@ void main() { ); }); - test('toJson - metadata', () { + test('toJson - metadata', () async { // mocked responses should return rolls of 6, 2, 1, 5 - final dice = DiceExpression.create('4d6 #cf #cs', seededRandom); - final obj = dice.roll().toJson(); + final dice = DiceExpression.create('4d6 #cf #cs', + diceRoller: DefaultDiceRoller(seededRandom)); + final obj = (await dice.roll()).toJson(); expect( obj, equals( @@ -803,7 +821,8 @@ void main() { }); test('rollN test', () async { - final dice = DiceExpression.create('2d6', seededRandom); + final dice = DiceExpression.create('2d6', + diceRoller: DefaultDiceRoller(seededRandom)); final results = await dice.rollN(2).map((result) => result.total).toList(); @@ -812,7 +831,8 @@ void main() { }); test('stats test', () async { - final dice = DiceExpression.create('2d6', seededRandom); + final dice = DiceExpression.create('2d6', + diceRoller: DefaultDiceRoller(seededRandom)); final stats = await dice.stats(num: 100); From 5f2bbebc74ee2d028b6d89bd9492197538b4e7fe Mon Sep 17 00:00:00 2001 From: Saif Date: Mon, 30 Jun 2025 09:56:38 +0200 Subject: [PATCH 2/3] Fix concurrency in tests --- test/dart_dice_parser_test.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/dart_dice_parser_test.dart b/test/dart_dice_parser_test.dart index 162abb5..8f70d9c 100644 --- a/test/dart_dice_parser_test.dart +++ b/test/dart_dice_parser_test.dart @@ -334,7 +334,7 @@ void main() { for (final v in invalids) { test('invalid count - $v', () { expect( - () => DiceExpression.create(v).roll(), + () async => DiceExpression.create(v).roll(), throwsFormatException, ); }); @@ -404,12 +404,12 @@ void main() { }); group('listeners', () { - test('basic', () { + test('basic', () async { final dice = DiceExpression.create('2d6 kh', diceRoller: DefaultDiceRoller(seededRandom)); final results = []; final summaries = []; - dice.roll( + await dice.roll( onRoll: (rr) { results.add(rr); }, @@ -652,9 +652,9 @@ void main() { expect((await dice.roll()).total, 6); }); - test('create dice with real random', () { + test('create dice with real random', () async { final dice = DiceExpression.create('10d100'); - final result1 = dice.roll(); + final result1 = await dice.roll(); // result will never be zero -- this test is verifying creating the expr & doing roll expect(result1, isNot(0)); }); @@ -693,13 +693,13 @@ void main() { }); } - test('toString', () { + test('toString', () async { // mocked responses should return rolls of 6, 2, 1, 5 final dice = DiceExpression.create( '(4d(3+3)! + (2+2)d6) #cs #cf #s #f', diceRoller: DefaultDiceRoller(seededRandom), ); - final out = dice.roll().toString(); + final out = (await dice.roll()).toString(); expect( out, equals( From 18b1bd2f6c0f3e87f9dd4957226a1fc72048dc75 Mon Sep 17 00:00:00 2001 From: Saif Date: Tue, 1 Jul 2025 12:24:36 +0200 Subject: [PATCH 3/3] Support flag for simultaneous rolls or not --- lib/src/ast.dart | 157 ++++++++++++++++++++++++++++++--------- lib/src/dice_roller.dart | 20 +++-- lib/src/parser.dart | 28 ++++--- 3 files changed, 153 insertions(+), 52 deletions(-) diff --git a/lib/src/ast.dart b/lib/src/ast.dart index e626c14..dea4f56 100644 --- a/lib/src/ast.dart +++ b/lib/src/ast.dart @@ -70,34 +70,55 @@ abstract class Binary extends DiceOp { /// multiply operation (flattens results) class MultiplyOp extends Binary { - MultiplyOp(super.name, super.left, super.right); + bool simultaneous; + MultiplyOp(super.name, super.left, super.right, {this.simultaneous = false}); @override Future eval() async { - final results = await Future.wait([left(), right()]); - return results[0] * results[1]; + if (simultaneous) { + final results = await Future.wait([left(), right()]); + return results[0] * results[1]; + } else { + final lhs = await left(); + final rhs = await right(); + return lhs * rhs; + } } } /// add operation class AddOp extends Binary { - AddOp(super.name, super.left, super.right); + bool simultaneous; + AddOp(super.name, super.left, super.right, {this.simultaneous = false}); @override Future eval() async { - final results = await Future.wait([left(), right()]); - return results[0] + results[1]; + if (simultaneous) { + final results = await Future.wait([left(), right()]); + return results[0] + results[1]; + } else { + final lhs = await left(); + final rhs = await right(); + return lhs + rhs; + } } } /// subtraction operation class SubOp extends Binary { - SubOp(super.name, super.left, super.right); + bool simultaneous; + SubOp(super.name, super.left, super.right, {this.simultaneous = false}); @override Future eval() async { - final results = await Future.wait([left(), right()]); - return results[0] - results[1]; + if (simultaneous) { + final results = await Future.wait([left(), right()]); + return results[0] - results[1]; + } else { + final lhs = await left(); + final rhs = await right(); + return lhs - rhs; + } } } @@ -231,16 +252,25 @@ class CountOp extends Binary { /// drop operations -- drop high/low, or drop <,>,= rhs class DropOp extends Binary { - DropOp(super.name, super.left, super.right); + bool simultaneous; + DropOp(super.name, super.left, super.right, {this.simultaneous = false}); @override Future eval() async { final lhs = left(); final rhs = right(); - final leftRight = await Future.wait([lhs, rhs]); - final finalLeft = leftRight[0]; - final finalRight = leftRight[1]; + final RollResult finalLeft; + final RollResult finalRight; + + if (simultaneous) { + final leftRight = await Future.wait([lhs, rhs]); + finalLeft = leftRight[0]; + finalRight = leftRight[1]; + } else { + finalLeft = await lhs; + finalRight = await rhs; + } final target = finalRight.totalOrDefault(() { throw FormatException( @@ -293,16 +323,25 @@ class DropOp extends Binary { /// drop operations -- drop high/low, or drop <,>,= rhs class DropHighLowOp extends Binary { - DropHighLowOp(super.name, super.left, super.right); + bool simultaneous; + DropHighLowOp(super.name, super.left, super.right, {this.simultaneous = false}); @override Future eval() async { final lhs = left(); final rhs = right(); - final leftRight = await Future.wait([lhs, rhs]); - final finalLeft = leftRight[0]; - final finalRight = leftRight[1]; + final RollResult finalLeft; + final RollResult finalRight; + + if (simultaneous) { + final leftRight = await Future.wait([lhs, rhs]); + finalLeft = leftRight[0]; + finalRight = leftRight[1]; + } else { + finalLeft = await lhs; + finalRight = await rhs; + } final sorted = finalLeft.results..sort(); final numToDrop = @@ -349,16 +388,25 @@ class DropHighLowOp extends Binary { /// clamp results of lhs to >,< rhs. class ClampOp extends Binary { - ClampOp(super.name, super.left, super.right); + bool simultaneous; + ClampOp(super.name, super.left, super.right, {this.simultaneous = false}); @override Future eval() async { final lhs = left(); final rhs = right(); - final leftRight = await Future.wait([lhs, rhs]); - final finalLeft = leftRight[0]; - final finalRight = leftRight[1]; + final RollResult finalLeft; + final RollResult finalRight; + + if (simultaneous) { + final leftRight = await Future.wait([lhs, rhs]); + finalLeft = leftRight[0]; + finalRight = leftRight[1]; + } else { + finalLeft = await lhs; + finalRight = await rhs; + } final target = finalRight.totalOrDefault(() { throw FormatException( @@ -541,7 +589,8 @@ class D66Dice extends UnaryDice { /// roll N dice of Y sides. class StdDice extends BinaryDice { - StdDice(super.name, super.left, super.right, super.roller); + bool simultaneous; + StdDice(super.name, super.left, super.right, super.roller, {this.simultaneous = false}); @override String toString() => '($left$name$right)'; @@ -551,9 +600,17 @@ class StdDice extends BinaryDice { final lhs = left(); final rhs = right(); - final leftRight = await Future.wait([lhs, rhs]); - final finalLeft = leftRight[0]; - final finalRight = leftRight[1]; + final RollResult finalLeft; + final RollResult finalRight; + + if (simultaneous) { + final leftRight = await Future.wait([lhs, rhs]); + finalLeft = leftRight[0]; + finalRight = leftRight[1]; + } else { + finalLeft = await lhs; + finalRight = await rhs; + } final ndice = finalLeft.totalOrDefault(() => 1); final nsides = finalRight.totalOrDefault(() => 1); @@ -588,12 +645,14 @@ class StdDice extends BinaryDice { } class RerollDice extends BinaryDice { + bool simultaneous; RerollDice( super.name, super.left, super.right, super.roller, { this.limit = defaultRerollLimit, + this.simultaneous = false, }) { if (name.startsWith('ro')) { limit = 1; @@ -607,9 +666,17 @@ class RerollDice extends BinaryDice { final lhs = left(); final rhs = right(); - final leftRight = await Future.wait([lhs, rhs]); - final finalLeft = leftRight[0]; - final finalRight = leftRight[1]; + final RollResult finalLeft; + final RollResult finalRight; + + if (simultaneous) { + final leftRight = await Future.wait([lhs, rhs]); + finalLeft = leftRight[0]; + finalRight = leftRight[1]; + } else { + finalLeft = await lhs; + finalRight = await rhs; + } if (finalLeft.nsides == 0) { throw FormatException( @@ -676,7 +743,7 @@ class RerollDice extends BinaryDice { // Await all rerolls in parallel. final rerolledValues = await Future.wait(rerollFutures); - int rerollIdx = 0; + var rerollIdx = 0; for (var i = 0; i < finalLeft.results.length; i++) { final v = finalLeft.results[i]; if (test(v)) { @@ -706,12 +773,14 @@ class RerollDice extends BinaryDice { } class CompoundingDice extends BinaryDice { + bool simultaneous; CompoundingDice( super.name, super.left, super.right, super.roller, { this.limit = defaultRerollLimit, + this.simultaneous = false, }) { if (name.startsWith('!!o')) { limit = 1; @@ -725,9 +794,17 @@ class CompoundingDice extends BinaryDice { final lhs = left(); final rhs = right(); - final leftRight = await Future.wait([lhs, rhs]); - final finalLeft = leftRight[0]; - final finalRight = leftRight[1]; + final RollResult finalLeft; + final RollResult finalRight; + + if (simultaneous) { + final leftRight = await Future.wait([lhs, rhs]); + finalLeft = leftRight[0]; + finalRight = leftRight[1]; + } else { + finalLeft = await lhs; + finalRight = await rhs; + } if (finalLeft.nsides == 0) { throw FormatException( @@ -817,12 +894,14 @@ class CompoundingDice extends BinaryDice { } class ExplodingDice extends BinaryDice { + bool simultaneous; ExplodingDice( super.name, super.left, super.right, super.roller, { this.limit = defaultRerollLimit, + this.simultaneous = false, }) { if (name.startsWith('!o')) { limit = 1; @@ -836,9 +915,17 @@ class ExplodingDice extends BinaryDice { final lhs = left(); final rhs = right(); - final leftRight = await Future.wait([lhs, rhs]); - final finalLeft = leftRight[0]; - final finalRight = leftRight[1]; + final RollResult finalLeft; + final RollResult finalRight; + + if (simultaneous) { + final leftRight = await Future.wait([lhs, rhs]); + finalLeft = leftRight[0]; + finalRight = leftRight[1]; + } else { + finalLeft = await lhs; + finalRight = await rhs; + } if (finalLeft.nsides == 0) { throw FormatException( diff --git a/lib/src/dice_roller.dart b/lib/src/dice_roller.dart index 1d5f8d4..e5efa33 100644 --- a/lib/src/dice_roller.dart +++ b/lib/src/dice_roller.dart @@ -20,6 +20,9 @@ abstract class DiceRoller with LoggingMixin { /// default limit to # of times dice rolls can explode (100) static const int defaultExplodeLimit = 100; + /// simultaneous rolls flag (impacts Futures queuing) + bool simultaneousRolls = false; + /// Roll ndice of nsides and return results as list. Future roll(int ndice, int nsides, [String msg = '']); @@ -33,7 +36,10 @@ abstract class DiceRoller with LoggingMixin { /// Default implementation of DiceRoller. class DefaultDiceRoller extends DiceRoller { /// Constructs a dice roller - DefaultDiceRoller([Random? r]) : _random = r ?? Random.secure(); + DefaultDiceRoller([Random? r, bool simultaneousRolls = false]) + : _random = r ?? Random.secure() { + this.simultaneousRolls = simultaneousRolls; + } final Random _random; @@ -44,8 +50,10 @@ class DefaultDiceRoller extends DiceRoller { @override Future roll(int ndice, int nsides, [String msg = '']) async { - RangeError.checkValueInInterval(ndice, DiceRoller.minDice, DiceRoller.maxDice, 'ndice'); - RangeError.checkValueInInterval(nsides, DiceRoller.minSides, DiceRoller.maxSides, 'nsides'); + RangeError.checkValueInInterval( + ndice, DiceRoller.minDice, DiceRoller.maxDice, 'ndice'); + RangeError.checkValueInInterval( + nsides, DiceRoller.minSides, DiceRoller.maxSides, 'nsides'); final results = [ for (int i = 0; i < ndice; i++) _random.nextInt(nsides) + 1, ]; @@ -64,7 +72,8 @@ class DefaultDiceRoller extends DiceRoller { @override Future rollFudge(int ndice) async { - RangeError.checkValueInInterval(ndice, DiceRoller.minDice, DiceRoller.maxDice, 'ndice'); + RangeError.checkValueInInterval( + ndice, DiceRoller.minDice, DiceRoller.maxDice, 'ndice'); final results = selectN(ndice, _fudgeVals); logger.finest(() => 'roll ${ndice}dF => $results'); @@ -80,7 +89,8 @@ class DefaultDiceRoller extends DiceRoller { @override Future rollVals(int ndice, List sideVals) async { - RangeError.checkValueInInterval(ndice, DiceRoller.minDice, DiceRoller.maxDice, 'ndice'); + RangeError.checkValueInInterval( + ndice, DiceRoller.minDice, DiceRoller.maxDice, 'ndice'); final results = selectN(ndice, sideVals); logger.finest(() => 'roll ${ndice}d$sideVals => $results'); diff --git a/lib/src/parser.dart b/lib/src/parser.dart index 1e8b886..869973a 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -44,7 +44,8 @@ Parser parserBuilder(DiceRoller roller) { ); builder.group().left( char('d').trim(), - (a, op, b) => StdDice(op, a, b, roller), + (a, op, b) => StdDice(op, a, b, roller, + simultaneous: roller.simultaneousRolls), ); // compounding dice (has to be in separate group from exploding) @@ -55,7 +56,8 @@ Parser parserBuilder(DiceRoller roller) { char('=').optional()) .flatten() .trim(), - (a, op, b) => CompoundingDice(op.toLowerCase(), a, b, roller), + (a, op, b) => CompoundingDice(op.toLowerCase(), a, b, roller, + simultaneous: roller.simultaneousRolls), ); builder.group() // reroll & reroll once @@ -66,7 +68,8 @@ Parser parserBuilder(DiceRoller roller) { char('=').optional()) .flatten() .trim(), - (a, op, b) => RerollDice(op.toLowerCase(), a, b, roller), + (a, op, b) => RerollDice(op.toLowerCase(), a, b, roller, + simultaneous: roller.simultaneousRolls), ) // exploding ..left( @@ -76,37 +79,38 @@ Parser parserBuilder(DiceRoller roller) { char('=').optional()) .flatten() .trim(), - (a, op, b) => ExplodingDice(op.toLowerCase(), a, b, roller), + (a, op, b) => ExplodingDice(op.toLowerCase(), a, b, roller, + simultaneous: roller.simultaneousRolls), ) // cap/clamp >,< ..left( (pattern('cC') & pattern('<>').optional()).flatten().trim(), - (a, op, b) => ClampOp(op.toLowerCase(), a, b), + (a, op, b) => ClampOp(op.toLowerCase(), a, b, simultaneous: roller.simultaneousRolls), ) // drop >=,<=,>,< ..left( (char('-') & pattern('<>') & char('=').optional()).flatten().trim(), - (a, op, b) => DropOp(op.toLowerCase(), a, b), + (a, op, b) => DropOp(op.toLowerCase(), a, b, simultaneous: roller.simultaneousRolls), ) ..left( (string('-=')).flatten().trim(), - (a, op, b) => DropOp(op.toLowerCase(), a, b), + (a, op, b) => DropOp(op.toLowerCase(), a, b, simultaneous: roller.simultaneousRolls), ) // drop(-) low, high ..left( (char('-') & pattern('LlHh')).flatten().trim(), - (a, op, b) => DropHighLowOp(op.toLowerCase(), a, b), + (a, op, b) => DropHighLowOp(op.toLowerCase(), a, b, simultaneous: roller.simultaneousRolls), ) // keep low/high ..left( (pattern('Kk') & pattern('LlHh').optional()).flatten().trim(), - (a, op, b) => DropHighLowOp(op.toLowerCase(), a, b), + (a, op, b) => DropHighLowOp(op.toLowerCase(), a, b, simultaneous: roller.simultaneousRolls), ); - builder.group().left(char('*').trim(), (a, op, b) => MultiplyOp(op, a, b)); + builder.group().left(char('*').trim(), (a, op, b) => MultiplyOp(op, a, b, simultaneous: roller.simultaneousRolls)); builder.group() - ..left(char('+').trim(), (a, op, b) => AddOp(op, a, b)) - ..left(char('-').trim(), (a, op, b) => SubOp(op, a, b)); + ..left(char('+').trim(), (a, op, b) => AddOp(op, a, b, simultaneous: roller.simultaneousRolls)) + ..left(char('-').trim(), (a, op, b) => SubOp(op, a, b, simultaneous: roller.simultaneousRolls)); // count >=, <=, <, >, =, // #s, #cs, #f, #cf -- count (critical) successes / failures builder.group().left(