owlbear
Version:
A simple dice notation parser.
250 lines (198 loc) • 7.84 kB
text/coffeescript
{_} = require 'underscore'
escapeForRegExp = require 'escape-regexp'
util = require 'util'
module.exports = class Parser
constructor: (options={})->
@operators = options.operators ?= Parser.DEFAULT_OPERATORS
parse: (n)->
parsed = []
chunks = n.toString().replace(/\s/g, '').split new RegExp "([#{escapeForRegExp @operators.join ''}])", 'i'
# Valid dice notation has to have an odd number of chunks: 1 roll or 1 roll
# followed by operator, roll pairs.
try
chunks.forEach (chunk, index)=>
if index % 2 is 0
parsed.push @parseTerm chunk
else
parsed.push @parseOperator chunk
catch err
throw new Error "Failed to parse '#{n}': #{err}"
parsed
compose: (p)->
composed = '';
for chunk in p
if chunk.operator?
unless _.contains @operators, chunk.operator
throw new Error "Cannot compose chunk: unknown operator '#{chunk.operator}'."
composed += chunk.operator
else if _.isNumber chunk
composed += chunk.toString()
else
if chunk.sides is 1
composed += chunk.count.toString()
else if chunk.constant?
composed += chunk.constant.toString()
else if chunk.die?
{ die, chain, keep, count } = chunk
t = "#{count}d#{die.sides}"
if keep? and keep != count
t += "k#{keep}"
if die.reroll? and die.reroll.length > 0
if die.reroll.length is 1 and die.reroll[0] is 1
t += 'R'
else
t += 'r' + die.reroll.join(',')
if die.explode? and die.explode.length > 0
if die.explode.length is 1 and die.explode[0] is die.sides
t += '!'
else
t += 'x' + die.explode.join(',')
char = if chain < 0 then '<' else '>'
t += char for i in [0...chain]
composed += t
else
throw new Error "Cannot compose unknown chunk:\n#{util.inspect chunk, null, false}"
composed
parseOperator: (n)->
o =
operator: n
parseTerm: (n)->
# The initial match finds the number and size of dice to roll and keeps
# the remainder for further analysis.
m = ///
^
([-+]?(?:[0-9]*\.[0-9]+|[0-9]+)) # get a (floating point?) number if we see one...
(?:
d([-+]?(?:[0-9]*\.[0-9]+|[0-9]+)) # capture the sides of the dice if this isn't a constant
( # and the rest, if present looks like
(?:k[+]?(?:[0-9]*\.[0-9]+|[0-9]+) # keep the highest n dice
|
[r|x][+-.0-9,]+ # reroll/explode these values
|
! # explode highest possible value
|
R # reroll lowest possible value
|
[<|>]+ # shift up or down on dice chain
)*
)?
)?
$
///.exec n
unless m?
throw new Error "Invalid term '#{n}'."
[ match, count, sides, modifiers ] = m
# NOTE: It's possible that, due to a funky dice chain, a roll of zero dice or dice with
# zero sides might advance to something other than zero. We can't just bail out now on 0.
# No starting digit? Count it as a 1.
# Since some systems might allow fractions of a die, we allow them, too.
count = if count?.valueOf() is '' then 1 else parseFloat count
nonNumericCount = isNaN count
# If we didn't find any sides (the x in 'ndx') we've got a constant, not a die.
unless sides?
if nonNumericCount
throw new Error "Non-numeric constant in term '#{n}'."
else
return parsed =
constant: count
# If we're still here, we've got some sort of dice.
if nonNumericCount or count < 0
throw new Error "Impossible number of dice (#{count}) in term '#{n}'."
# Theoretically, floating point sides might make sense. Negative ones, no.
sides = parseFloat sides
if isNaN sides or sides < 0
throw new Error "Impossible number of sides (#{sides}) in term '#{n}'."
parsed =
count: count
chain: 0
keep: count
die:
sides: sides
reroll: []
explode: []
if modifiers?
s = ///
k([+]?(?:[0-9]*\.[0-9]+|[0-9]+)) # keep this many
|
(R) # reroll lowest value
|
r([+-.0-9,]+) # reroll these values
|
(>) # move up die chain
|
(<) # move down die chain
|
(!) # explode higest value
|
x([+-.0-9,]+) # explode these values
///g
while modifierMatch = s.exec m[3]
[ matched, keep, rerollMin, rerollValues, chainUp, chainDown, explodeMax, explodeValues ] = modifierMatch
# If we can have floating point numbers of dice, we need to be able to keep a floating
# point number of them. No idea what 3.4d6k3.2 might mean, though.
if keep?
parsed.keep = parseFloat keep
else if rerollMin?
parsed.die.reroll.push 1
else if rerollValues?
rerollValues.split(',').forEach (r)->
v = parseFloat r
if isNaN v
throw new Error "Cannot parse reroll value '#{r}'."
parsed.die.reroll.push v
else if chainUp?
parsed.chain++
else if chainDown?
parsed.chain--
else if explodeMax?
parsed.die.explode.push 'MAX'
else if explodeValues?
explodeValues.split(',').forEach (x)->
v = parseFloat x
if isNaN v
throw new Error "Cannot parse explode value '#{x}'."
parsed.die.explode.push v
else
throw new Error "Cannot parse subterm '#{m[3]}'."
# Clean up the parsed results.
parsed.die.reroll = _.uniq parsed.die.reroll.filter (v)-> v >= 0 && v <= parsed.die.sides
index = parsed.die.explode.indexOf 'MAX'
unless -1 is index
parsed.die.explode[index] = parsed.die.sides
parsed.die.explode = _.uniq parsed.die.explode.filter (v)-> v >= 0 && v <= parsed.die.sides
# validate that this roll can actually happen.
if parsed.count < parsed.keep or 0 > parsed.keep
throw new Error "Cannot keep '#{parsed.keep}' of '#{parsed.count}' dice."
if 0 > parsed.die.sides
throw new Error "Cannot have a die with '#{parsed.die.sides}' sides."
# No possible value can explode AND reroll. Here's we're assuming that wacky
# floating point dice will use the same epsilon value to determine what rerolls
# and what explodes.
intersection = _.intersection parsed.die.reroll, parsed.die.explode
if intersection.length > 0
throw new Error "Cannot both reroll and explode the same values: '#{intersection}'"
# When using integer dice, like a normal person, we can validate a die can actually terminate rolling.
if parseInt parsed.die.sides is parsed.die.sides
unless _.union(parsed.die.reroll, parsed.die.explode).length < parsed.die.sides
throw new Error "No possible rolls neither explode nor reroll."
parsed
@DEFAULT_OPERATORS = [ '/', '*', '+', '-' ]
###
_findIndexInChain: (die)->
position = -1
@chain.forEach (chainDie, index)=>
if die.sides <= chainDie.sides
position++
return Math.max 0, position
_shiftAlongChain: (die, move)->
if move is 0
return die
index = @_findIndexInChain die
l = @chain.length
i = Math.min l, i
moved = Math.max 0, Math.min l, (i + move)
@chain[moved]
@DCC_CHAIN = [ '1d3', '1d4', '1d5', '1d6', '1d7', '1d8', '1d10', '1d12', '1d14', '1d16', '1d20', '1d24', '1d30' ]
@DND_CHAIN = [ '1d4', '1d6', '1d8', '1d10', '1d12', '1d20' ]
@DEFAULT_CHAIN = @DCC_CHAIN
###