UNPKG

owlbear

Version:

A simple dice notation parser.

250 lines (198 loc) 7.84 kB
{_} = 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 ###