Parsing dice expressions (e.g. 3d6+5) in C#: where to start?
Asked Answered
W

4

13

So I want to be able to parse, and evaluate, "dice expressions" in C#. A dice expression is defined like so:

<expr> :=   <expr> + <expr>
            | <expr> - <expr>
            | [<number>]d(<number>|%)
            | <number>
<number> := positive integer

So e.g. d6+20-2d3 would be allowed, and should evaluate as

rand.Next(1, 7) + 20 - (rand.Next(1, 4) + rand.Next(1, 4))

Also d% should be equivalent to d100.

I know I could hack together some solution, but I also know that this seems like a very typical computer-science type problem, so there must be some super-elegant solution I should look into.

I'd like the result of my parsing to have these capabilities:

  • I should be able to output a normalized form of the expression; I'm thinking dice first, sorted by dice size, and always with a prefix. So e.g. the above sample would become 1d6-2d3+20. Also any instances of d% would become d100 in the normalized form.
  • I should be able to evaluate the expression at-will, rolling different random numbers each time.
  • I should be able to evaluate the expression with all of the dice-rolls maximized, so e.g. the sample above would give (deterministically) 1*6+20+2*3 = 32.

I know that this is exactly the type of thing Haskell, and probably other functional-type languages, would be great at, but I'd like to stay in C# if possible.

My initial thoughts tend toward recursion, lists, and maybe some LINQ, but again, if I tried without some pointers from people who know things, I'm sure it'd end up being an inelegant mess.

Another tactic that might work would be some initial regex-based string-replacement to turn dice expressions into rand.Next calls, and then on-the-fly evaluation or compilation... would this actually work? How could I avoid creating a new rand object every time?

Weasel answered 9/8, 2009 at 9:12 Comment(3)
random.Next's first parameter is inclusive, and it's second parameter is exclusive. msdn.microsoft.com/en-us/library/2dx6wyd4%28VS.95%29.aspxWeasel
I guess you mean: rand.Next(1, 7) + 20 - (rand.Next(1, 4) + rand.Next(1, 4))Peak
Hmm, I would start with at least a +3 parsing tool. :-)Hearttoheart
W
14

Here's what I eventually came up with:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

public enum DiceExpressionOptions
{
    None,
    SimplifyStringValue
}
public class DiceExpression
{
    /* <expr> :=   <expr> + <expr>
     *           | <expr> - <expr>
     *           | [<number>]d(<number>|%)
     *           | <number>
     * <number> := positive integer
     * */
    private static readonly Regex numberToken = new Regex("^[0-9]+$");
    private static readonly Regex diceRollToken = new Regex("^([0-9]*)d([0-9]+|%)$");

    public static readonly DiceExpression Zero = new DiceExpression("0");

    private List<KeyValuePair<int, IDiceExpressionNode>> nodes = new List<KeyValuePair<int, IDiceExpressionNode>>();

    public DiceExpression(string expression)
        : this(expression, DiceExpressionOptions.None)
    { }
    public DiceExpression(string expression, DiceExpressionOptions options)
    {
        // A well-formed dice expression's tokens will be either +, -, an integer, or XdY.
        var tokens = expression.Replace("+", " + ").Replace("-", " - ").Split(' ', StringSplitOptions.RemoveEmptyEntries);

        // Blank dice expressions end up being DiceExpression.Zero.
        if (!tokens.Any())
        {
            tokens = new[] { "0" };
        }

        // Since we parse tokens in operator-then-operand pairs, make sure the first token is an operand.
        if (tokens[0] != "+" && tokens[0] != "-")
        {
            tokens = (new[] { "+" }).Concat(tokens).ToArray();
        }

        // This is a precondition for the below parsing loop to make any sense.
        if (tokens.Length % 2 != 0)
        {
            throw new ArgumentException("The given dice expression was not in an expected format: even after normalization, it contained an odd number of tokens.");
        }

        // Parse operator-then-operand pairs into this.nodes.
        for (int tokenIndex = 0; tokenIndex < tokens.Length; tokenIndex += 2)
        {
            var token = tokens[tokenIndex];
            var nextToken = tokens[tokenIndex + 1];

            if (token != "+" && token != "-")
            {
                throw new ArgumentException("The given dice expression was not in an expected format.");
            }
            int multiplier = token == "+" ? +1 : -1;

            if (DiceExpression.numberToken.IsMatch(nextToken))
            {
                this.nodes.Add(new KeyValuePair<int, IDiceExpressionNode>(multiplier, new NumberNode(int.Parse(nextToken))));
            }
            else if (DiceExpression.diceRollToken.IsMatch(nextToken))
            {
                var match = DiceExpression.diceRollToken.Match(nextToken);
                int numberOfDice = match.Groups[1].Value == string.Empty ? 1 : int.Parse(match.Groups[1].Value);
                int diceType = match.Groups[2].Value == "%" ? 100 : int.Parse(match.Groups[2].Value);
                this.nodes.Add(new KeyValuePair<int, IDiceExpressionNode>(multiplier, new DiceRollNode(numberOfDice, diceType)));
            }
            else
            {
                throw new ArgumentException("The given dice expression was not in an expected format: the non-operand token was neither a number nor a dice-roll expression.");
            }
        }

        // Sort the nodes in an aesthetically-pleasing fashion.
        var diceRollNodes = this.nodes.Where(pair => pair.Value.GetType() == typeof(DiceRollNode))
                                      .OrderByDescending(node => node.Key)
                                      .ThenByDescending(node => ((DiceRollNode)node.Value).DiceType)
                                      .ThenByDescending(node => ((DiceRollNode)node.Value).NumberOfDice);
        var numberNodes = this.nodes.Where(pair => pair.Value.GetType() == typeof(NumberNode))
                                    .OrderByDescending(node => node.Key)
                                    .ThenByDescending(node => node.Value.Evaluate());

        // If desired, merge all number nodes together, and merge dice nodes of the same type together.
        if (options == DiceExpressionOptions.SimplifyStringValue)
        {
            int number = numberNodes.Sum(pair => pair.Key * pair.Value.Evaluate());
            var diceTypes = diceRollNodes.Select(node => ((DiceRollNode)node.Value).DiceType).Distinct();
            var normalizedDiceRollNodes = from type in diceTypes
                                          let numDiceOfThisType = diceRollNodes.Where(node => ((DiceRollNode)node.Value).DiceType == type).Sum(node => node.Key * ((DiceRollNode)node.Value).NumberOfDice)
                                          where numDiceOfThisType != 0
                                          let multiplicand = numDiceOfThisType > 0 ? +1 : -1
                                          let absNumDice = Math.Abs(numDiceOfThisType)
                                          orderby multiplicand descending
                                          orderby type descending
                                          select new KeyValuePair<int, IDiceExpressionNode>(multiplicand, new DiceRollNode(absNumDice, type));

            this.nodes = (number == 0 ? normalizedDiceRollNodes
                                      : normalizedDiceRollNodes.Concat(new[] { new KeyValuePair<int, IDiceExpressionNode>(number > 0 ? +1 : -1, new NumberNode(number)) })).ToList();
        }
        // Otherwise, just put the dice-roll nodes first, then the number nodes.
        else
        {
            this.nodes = diceRollNodes.Concat(numberNodes).ToList();
        }
    }

    public override string ToString()
    {
        string result = (this.nodes[0].Key == -1 ? "-" : string.Empty) + this.nodes[0].Value.ToString();
        foreach (var pair in this.nodes.Skip(1))
        {
            result += pair.Key == +1 ? " + " : " − "; // NOTE: unicode minus sign, not hyphen-minus '-'.
            result += pair.Value.ToString();
        }
        return result;
    }
    public int Evaluate()
    {
        int result = 0;
        foreach (var pair in this.nodes)
        {
            result += pair.Key * pair.Value.Evaluate();
        }
        return result;
    }
    public decimal GetCalculatedAverage()
    {
        decimal result = 0;
        foreach (var pair in this.nodes)
        {
            result += pair.Key * pair.Value.GetCalculatedAverage();
        }
        return result;
    }

    private interface IDiceExpressionNode
    {
        int Evaluate();
        decimal GetCalculatedAverage();
    }
    private class NumberNode : IDiceExpressionNode
    {
        private int theNumber;
        public NumberNode(int theNumber)
        {
            this.theNumber = theNumber;
        }
        public int Evaluate()
        {
            return this.theNumber;
        }

        public decimal GetCalculatedAverage()
        {
            return this.theNumber;
        }
        public override string ToString()
        {
            return this.theNumber.ToString();
        }
    }
    private class DiceRollNode : IDiceExpressionNode
    {
        private static readonly Random roller = new Random();

        private int numberOfDice;
        private int diceType;
        public DiceRollNode(int numberOfDice, int diceType)
        {
            this.numberOfDice = numberOfDice;
            this.diceType = diceType;
        }

        public int Evaluate()
        {
            int total = 0;
            for (int i = 0; i < this.numberOfDice; ++i)
            {
                total += DiceRollNode.roller.Next(1, this.diceType + 1);
            }
            return total;
        }

        public decimal GetCalculatedAverage()
        {
            return this.numberOfDice * ((this.diceType + 1.0m) / 2.0m);
        }

        public override string ToString()
        {
            return string.Format("{0}d{1}", this.numberOfDice, this.diceType);
        }

        public int NumberOfDice
        {
            get { return this.numberOfDice; }
        }
        public int DiceType
        {
            get { return this.diceType; }
        }
    }
}
Weasel answered 21/11, 2009 at 16:15 Comment(0)
L
5

you could use your grammar in a compiler-compiler (something like Yacc) for C# (like antlr) or just start to write your recursive descent parser.

Then you build a in-memory data structure (a tree if you want arbitrary math operations other than +) that is Visitable so you need to write a couple of visitors:

  • RollVisitor: init a rand seed then visiting each node, accumulating result
  • GetMaxVisitor: sum the upper bound of each dice
  • other visitors? (such as PrettyPrintVisitor, RollTwiceVisitor, etc etc)

I think that a visitable-tree is a worthy solution here.

Levan answered 9/8, 2009 at 9:16 Comment(4)
Though to be fair, this does seem like overkill to me.Darendaresay
@Greg: it is a standard design for a parse tree in a OO way... why do you think that it is overkill? do you prefer a single line full of regexp?Levan
I haven't analyzed the provided grammar, but it seems small enough that going for a fully-fledged codegen solution might be less simple than the problem space calls for. If I had my reference text with me for a memory refresher, I'd be tempted to sketch the appropriate automata on the spot.Darendaresay
ok you got a point, however I also mentioned a resursive descent parser that should be straightforward for this kind of grammarsLevan
H
5

Some attempts:

Evaluate dice rolling notation strings

Harpsichord answered 9/8, 2009 at 9:30 Comment(1)
Excellent! It seemed likely to have shown up on SO before, but I couldn't find it... I edited the tags to make it a bit more discoverable.Weasel
C
0

You should take a look at this article at CodeProject: http://www.codeproject.com/KB/cpp/rpnexpressionevaluator.aspx. I explains how to convert infix expression to postfix one and then evaluate it.

For parsing, I think you can handle it with regular expressions.

Coliseum answered 9/8, 2009 at 9:20 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.