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:
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.
···
#
# 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