lib/games_dice/parser.rb
# frozen_string_literal: true
require 'parslet'
module GamesDice
# Based on the parslet gem, this class defines the dice mini-language used by GamesDice.create
#
# An instance of this class is a parser for the language. There are no user-definable instance
# variables.
#
class Parser < Parslet::Parser
# Parslet rules that define the dice string grammar.
rule(:integer) { match('[0-9]').repeat(1) }
rule(:plus_minus_integer) { (match('[+-]') >> integer) | integer }
rule(:range) { integer.as(:range_start) >> str('..') >> integer.as(:range_end) }
rule(:dlabel) { match('[d]') }
rule(:space) { match('\s').repeat(1) }
rule(:space?) { space.maybe }
rule(:underscore) { str('_').repeat(1) }
rule(:underscore?) { space.maybe }
rule(:bunch_start) { integer.as(:ndice) >> dlabel >> integer.as(:sides) }
rule(:reroll_label) { match(['r']).as(:reroll) }
rule(:keep_label) { match(['k']).as(:keep) }
rule(:map_label) { match(['m']).as(:map) }
rule(:alias_label) { match(['x']).as(:alias) }
rule(:single_modifier) { alias_label }
rule(:modifier_label) { reroll_label | keep_label | map_label }
rule(:simple_modifier) { modifier_label >> integer.as(:simple_value) }
rule(:comparison_op) { str('>=') | str('<=') | str('==') | str('>') | str('<') }
rule(:ctl_string) { match('[a-z_]').repeat(1) }
rule(:output_string) { match('[A-Za-z0-9_]').repeat(1) }
rule(:opint_or_int) { (comparison_op.as(:comparison) >> integer.as(:compare_num)) | integer.as(:compare_num) }
rule(:comma) { str(',') }
rule(:stop) { str('.') }
rule(:condition_only) { opint_or_int.as(:condition) }
rule(:num_only) { integer.as(:num) }
rule(:condition_and_type) { opint_or_int.as(:condition) >> comma >> ctl_string.as(:type) }
rule(:condition_and_num) { opint_or_int.as(:condition) >> comma >> plus_minus_integer.as(:num) }
rule(:condition_type_and_num) do
opint_or_int.as(:condition) >> comma >> ctl_string.as(:type) >> comma >> integer.as(:num)
end
rule(:condition_num_and_output) do
opint_or_int.as(:condition) >> comma >> plus_minus_integer.as(:num) >> comma >> output_string.as(:output)
end
rule(:num_and_type) { integer.as(:num) >> comma >> ctl_string.as(:type) }
rule(:reroll_params) { condition_type_and_num | condition_and_type | condition_only }
rule(:map_params) { condition_num_and_output | condition_and_num | condition_only }
rule(:keeper_params) { num_and_type | num_only }
rule(:full_reroll) { reroll_label >> str(':') >> reroll_params >> stop }
rule(:full_map) { map_label >> str(':') >> map_params >> stop }
rule(:full_keepers) { keep_label >> str(':') >> keeper_params >> stop }
rule(:complex_modifier) { full_reroll | full_map | full_keepers }
rule(:bunch_modifier) { complex_modifier | (single_modifier >> stop.maybe) | (simple_modifier >> stop.maybe) }
rule(:bunch) { bunch_start >> bunch_modifier.repeat.as(:mods) }
rule(:operator) { match('[+-]').as(:op) >> space? }
rule(:add_bunch) { operator >> bunch >> space? }
rule(:add_constant) { operator >> integer.as(:constant) >> space? }
rule(:dice_expression) { add_bunch | add_constant }
rule(:expressions) { dice_expression.repeat.as(:bunches) }
root :expressions
# Parses a string description in the dice mini-language, and returns data for feeding into
# GamesDice::Dice constructor.
# @param [String] dice_description Text to parse e.g. '1d6'
# @return [Hash] Analysis of dice_description
def parse(dice_description)
dice_description = dice_description.to_s.strip
# Force first item to start '+' for simpler parse rules
dice_description = "+#{dice_description}" unless dice_description =~ /\A[+-]/
dice_expressions = super(dice_description)
{
bunches: ParseTreeProcessor.collect_bunches(dice_expressions),
offset: ParseTreeProcessor.collect_offset(dice_expressions)
}
end
# Class converts parse tree to GamesDice hash model
# @!visibility private
class ParseTreeProcessor
class << self
def collect_bunches(dice_expressions)
dice_expressions[:bunches].select { |h| h[:ndice] }.map do |in_hash|
out_hash = {}
collect_bunch_basics(in_hash, out_hash)
collect_bunch_multiplier(in_hash, out_hash) if in_hash[:op]
in_hash[:mods]&.each do |mod|
collect_bunch_modifier(mod, out_hash)
end
out_hash
end
end
def collect_bunch_basics(in_hash, out_hash)
%i[ndice sides].each do |s|
next unless in_hash[s]
out_hash[s] = in_hash[s].to_i
end
end
def collect_bunch_multiplier(in_hash, out_hash)
optype = in_hash[:op].to_s
out_hash[:multiplier] = case optype
when '+' then 1
when '-' then -1
end
end
def collect_bunch_modifier(mod, out_hash)
if mod[:alias]
ParseTreeBunchModifier.collect_alias_modifier mod, out_hash
elsif mod[:keep]
ParseTreeBunchModifier.collect_keeper_rule mod, out_hash
elsif mod[:map]
ParseTreeBunchModifier.collect_map_rule mod, out_hash
elsif mod[:reroll]
ParseTreeBunchModifier.collect_reroll_rule mod, out_hash
end
end
def collect_offset(dice_expressions)
dice_expressions[:bunches].select { |h| h[:constant] }.inject(0) do |total, in_hash|
c = in_hash[:constant].to_i
optype = in_hash[:op].to_s
if optype == '+'
total + c
else
total - c
end
end
end
end
end
# Class for collating bunch data into bunch construction hash
# @!visibility private
class ParseTreeBunchModifier
class << self
# Called when we have a single letter convenient alias for common dice adjustments
def collect_alias_modifier(alias_mod, out_hash)
alias_name = alias_mod[:alias].to_s
case alias_name
when 'x' # Exploding re-roll
out_hash[:rerolls] ||= []
out_hash[:rerolls] << [out_hash[:sides], :==, :reroll_add]
end
end
# Called for any parsed reroll rule
def collect_reroll_rule(reroll_mod, out_hash)
out_hash[:rerolls] ||= []
if reroll_mod[:simple_value]
out_hash[:rerolls] << [reroll_mod[:simple_value].to_i, :>=, :reroll_replace]
return
end
collect_complex_reroll_rule(reroll_mod, out_hash)
end
def collect_complex_reroll_rule(reroll_mod, out_hash)
# Typical reroll_mod: {:reroll=>"r"@5, :condition=>{:compare_num=>"10"@7}, :type=>"add"@10}
op = get_op_symbol(reroll_mod[:condition][:comparison] || '==')
v = reroll_mod[:condition][:compare_num].to_i
type = "reroll_#{reroll_mod[:type] || 'replace'}".to_sym
out_hash[:rerolls] << if reroll_mod[:num]
[v, op, type, reroll_mod[:num].to_i]
else
[v, op, type]
end
end
# Called for any parsed keeper mode
def collect_keeper_rule(keeper_mod, out_hash)
raise 'Cannot set keepers for a bunch twice' if out_hash[:keep_mode]
if keeper_mod[:simple_value]
out_hash[:keep_mode] = :keep_best
out_hash[:keep_number] = keeper_mod[:simple_value].to_i
return
end
# Typical keeper_mod: {:keep=>"k"@5, :num=>"1"@7, :type=>"worst"@9}
out_hash[:keep_number] = keeper_mod[:num].to_i
out_hash[:keep_mode] = "keep_#{keeper_mod[:type] || 'best'}".to_sym
end
# Called for any parsed map mode
def collect_map_rule(map_mod, out_hash)
out_hash[:maps] ||= []
if map_mod[:simple_value]
out_hash[:maps] << [map_mod[:simple_value].to_i, :<=, 1]
return
end
collect_complex_map_rule(map_mod, out_hash)
end
def collect_complex_map_rule(map_mod, out_hash)
# Typical map_mod: {:map=>"m"@4, :condition=>{:compare_num=>"5"@6}, :num=>"2"@8, :output=>"Qwerty"@10}
op = get_op_symbol(map_mod[:condition][:comparison] || '>=')
v = map_mod[:condition][:compare_num].to_i
out_val = 1
out_val = map_mod[:num].to_i if map_mod[:num]
out_hash[:maps] << if map_mod[:output]
[v, op, out_val, map_mod[:output].to_s]
else
[v, op, out_val]
end
end
# The dice description language uses (r).op.x, whilst GamesDice::RerollRule uses x.op.(r), so
# as well as converting to a symbol, we must reverse sense of input to constructor
OP_CONVERSION = {
'==' => :==,
'>=' => :<=,
'>' => :<,
'<' => :>,
'<=' => :>=
}.freeze
def get_op_symbol(parsed_op_string)
OP_CONVERSION[parsed_op_string.to_s]
end
end
end
end
end