Hi,

This is my quiz entry for Ruby Quiz 61 (Dice Roller). It's actually the
second idea I had, after starting out with Antlr (I still finished that
one, because I wanted to get to grips with Antlr anyway - I happened to
be playing with it when this quiz came out :)). I've bundled both this
entry and that one at:

   http://roscopeco.co.uk/code/ruby-quiz-entries/quiz61-dice-roller.tar.gz

Anyway, back to my real entry. I guess I took the short-cut route to
the dice-roller, and instead of parsing out the expressions I instead
decided to 'coerce' them to Ruby code, by just implementing the 'd'
operator with a 'rolls' method on Fixnum, and using gsub to convert
the input expression.

   d3*2                  =>   1.rolls(3)*2
   (5d5-4)d(16/d4)+3     =>   (5.rolls(5)-4).rolls(16/1.rolls(4))+3
   d%*7                  =>   1.rolls(100)*7

This is implemented in the DiceRoller.parse method, which returns the
string. You can just 'eval' this of course, or use the 'roll' method
(also provided as a more convenient class method that wraps the whole
thing up for you) to do it. Ruby runs the expression, and gives back
the result. I almost feel like I cheated...?

As well as the main 'roll.rb' I also included a separate utility that
uses loaded dice to find min/max achievable. All three files can be
executed, and if you enable --verbose mode on Ruby you'll see the
dice rolls and parsed expressions.

----------[MAIN (roll.rb)]-----------
#!/usr/local/bin/ruby
#
# Ruby Quiz 61, the quick way
# by Ross Bamford

# Just a debugging helper
module Kernel
   def dbg(*s)
     puts(*s) if $VERBOSE|| @dice_debug
   end
   attr_writer :dice_debug
   def dice_debug?; @dice_debug; end
end

# Need to implement the 'rolls' method. Wish it didn't have to
# be on Fixnum but for this it makes the parsing *lots* easier.
class Fixnum
   def self.roll_proc=(blk)
     @roll_proc = blk
   end

  def self.roll_proc
     @roll_proc ||= method(:rand).to_proc
   end

   def rolls(sides)
     (1..self).inject(0) { |s,v| s + Fixnum.roll_proc[sides] }
   end
end

# Here's the roller.
class DiceRoller
   class << self
     # Completely wrap up a roll
     def roll(expr, count = 1, debug = false)
       new(expr,debug).roll(count)
     end

     # The main 'parse' method. Just really coerces the code to Ruby
     # and then compiles to a block that returns the result.
     def parse(expr)
       # very general check here. Will pass lots of invalid syntax,
       # but hopefully that won't compile later. This removes the
       # possibility of using variables and the like, but that wasn't
       # required anyway. The regexps would be a bit more difficult
       # if we wanted to do that.
       raise SyntaxError, "'#{expr}' is not a valid dice expression", [] if  
expr =~ /[^d\d\(\)\+\-\*\/\%]|[^d]%|d-|\*\*/

       # Rubify!
       s = expr.gsub( /([^\d\)])d|^d/,   '\11d')          # fix e.g. 'd5'  
and '33+d3' to '1.d5' and '33+1d3'
       s.gsub!(       /d%/,              'd(100)'  )      # fix e.g. 'd%'  
to 'd(100)'
       s.gsub!(       /d([\+\-]?\d+)/,   '.rolls(\1)')    # fix e.g. '3d8'  
to '3.rolls(8) (*)
       s.gsub!(       /d\(/,             '.rolls(')       # fix e.g.  
'2d(5+5)' to '2.rolls(5+5)'

       # (*) This line treats + or - straight after 'd' as a unary sign,
       # so you can have '3d-8*7' => '3.rolls(+8)-7'
       # This would throw a runtime error from rolls, though.

       # Make a block. Doing it this way gets Ruby to compile it now
       # so we'll reliably get fail fast on bad syntax.
       dbg "PARS: #{expr} => #{s}"
       begin
         eval("lambda { #{s} }")
       rescue Exception => ex
         raise SyntaxError, "#{expr} is not a valid dice expression", []
       end
     end
   end

   # Create a new roller that rolls the specified dice expression
   def initialize(expr, debug = false)
     dbg "NEW : #{to_s}: #{expr} => #{expr_code}"
     @expr_code, @expr, @debug = expr, DiceRoller.parse(expr), debug
   end

   # Get hold of the original expression and compiled block, respectively
   attr_reader :expr_code, :expr

   # Roll this roller count times
   def roll(count = 1)
     dbg "  ROLL: #{to_s}: #{count} times"
     r = (1..count).inject([]) do |totals,v|
       this_r = begin
         expr.call
       rescue Exception => ex
         raise RuntimeError, "'#{expr_code}' raised: #{ex}", []
       end

       dbg "    r#{v}: rolled #{this_r}"
       totals << this_r
     end

     r.length < 2 ? r[0] : r
   end
end

# Library usage:
#
#   require 'roll'
#
#   # is the default:
#   # Fixnum.roll_proc = lambda { |sides| rand(sides) + 1 }
#
#   DiceRoller.roll('1+2*d6')
#
#   d = DiceRoller.new('((3d%)+8*(d(5*5)))')
#   d.roll(5)
#
#   d = DiceRoller.new('45*10d3')   # debug
#
#   # ... or
#   one_roll = d.expr.call
#

# command-line usage
if $0 == __FILE__
   unless expr = ARGV[0]
     puts "Usage: ruby [--verbose] roll.rb expr [count]"
   else
     (ARGV[1] || 1).to_i.times { print "#{DiceRoller.roll(expr)}  " }
     print "\n"
   end
end
=====================================




-----------[UTIL: minmax.rb]----------
#!/usr/local/bin/ruby

require 'roll'

LOW_DICE  = lambda { |sides| 1 }
HIGH_DICE = lambda { |sides| sides }

# Adds a 'minmax' method that uses loaded dice to find
# min/max achievable for a given expression.
#
# Obviously not thread safe, but then neither is the
# whole thing ;D
class DiceRoller
   def self.minmax(expr)
     old_proc = Fixnum.roll_proc
     Fixnum.roll_proc = LOW_DICE
     low = DiceRoller.roll(expr)

     Fixnum.roll_proc = HIGH_DICE
     high = DiceRoller.roll(expr)
     Fixnum.roll_proc = old_proc

     [low,high]
   end
end

if $0 == __FILE__
   if expr = ARGV[0]
     min, max = DiceRoller.minmax(expr)
     puts "Expression: #{expr} ; min / max = #{min} / #{max}"
   else
     puts "Usage: minmax.rb <expr>"
   end
end
=====================================





-----------[TEST: test.rb]----------
#!/usr/local/bin/ruby
#
# Ruby Quiz, number 61 - Dice roller
# This entry by Ross Bamford (rosco<at>roscopeco.co.uk)

require 'test/unit'
require 'roll'

ASSERTS = {
   '1'                   => 1,
   '1+2'                 => 3,
   '1+3*4'               => 13,
   '1*2+4/8-1'           => 1,
   'd1'                  => 1,
   '1d1'                 => 1,
   'd10'                 => 10,
   '1d10'                => 10,
   '10d10'               => 100,
   'd3*2'                => 6,
   '5d6d7'               => 210,   # left assoc
   '2d3+8'               => 14,    # not 22
   '(2d(3+8))'           => 22,    # not 14
   'd3+d3'               => 6,
   '33+d3+10'            => 46,
   'd2*2d4'              => 16,
   'd(2*2)+d4'           => 8,
   'd%'                  => 100,
   '2d%'                 => 200,
   'd%*7'                => 700,
   '14+3*10d2'           => 74,
   '(5d5-4)d(16/d4)+3'   => 87,    #25d4 + 3
   '3d+8/8'              => 3      #3d(+8)/8
}

ERRORS = {

   # Bad input, all should raise exception
   'd'                   => SyntaxError,
   '3d'                  => SyntaxError,
   '3d-8'                => SyntaxError,  # - # of sides
   '3ddd6'               => SyntaxError,
   '3%2'                 => SyntaxError,
   '%d'                  => SyntaxError,
   '+'                   => SyntaxError,
   '4**3'                => SyntaxError
}

# bit messy, but can't get class methods on Fixnum
Fixnum.roll_proc = lambda { |sides| sides }

class TestDiceRoller < Test::Unit::TestCase
   def initialize(*args)
     super
   end

   ASSERTS.each do |expr, expect|
     eval <<-EOC
       def test_good_#{expr.hash.abs}
         expr, expect = #{expr.inspect}, #{expect.inspect}
         puts "\n-----------------------\n\#{expr} => \#{expect}" if  
$VERBOSE
         res = DiceRoller.roll(expr)
         puts "Returned \#{res}\n-----------------------" if $VERBOSE
         assert_equal expect, res
       end
     EOC
   end

   ERRORS.each do |expr, expect|
     eval <<-EOC
       def test_error_#{expr.hash.abs}
         expr, expect = #{expr.inspect}, #{expect.inspect}
         assert_raise(#{expect}) do
           puts "\n-----------------------\n\#{expr} => \#{expect}" if  
$VERBOSE
           res = DiceRoller.roll(expr)
           puts "Returned \#{res}\n-----------------------" if $VERBOSE
         end
       end
     EOC
   end
end
=====================================

-- 
Ross Bamford - rosco / roscopeco.remove.co.uk