diff --git a/app/models/stat.rb b/app/models/stat.rb index 3fdc7bf..35fab16 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -11,11 +11,22 @@ class Stat < ApplicationRecord uniqueness: true validates :value, presence: true, numericality: true + validate :validate_roll_command before_validation :set_slug + def roll + DiceRoller.new(roll_command, stat: self).roll + end + private + def validate_roll_command + return if roll_command.blank? + + DiceRoller.new(roll_command).valid? + end + def set_slug return if slug.present? || name.blank? diff --git a/app/services/dice_roller.rb b/app/services/dice_roller.rb new file mode 100644 index 0000000..bdbabc3 --- /dev/null +++ b/app/services/dice_roller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class DiceRoller + def initialize(roll_command, stat: nil) + @roll_command = roll_command + @stat = stat&.value + end + + def roll + result = 0 + operator = nil + roll_command_parts.each do |part| + case part + when /\A\d*d\d+\z/ + operator.nil? ? (result += roll_dice(part)) : (result = result.send(operator, roll_dice(part))) + when /\A\d+\z/ + operator.nil? ? (result += part.to_i) : (result = result.send(operator, part.to_i)) + when "self" + operator.nil? ? (result += @stat) : (result = result.send(operator, @stat)) + when /\A[+\-*\/]\z/ + operator = part + end + end + result + end + + def valid? + return if @roll_command.blank? + + # No repeated math operators + return false if @roll_command.match?(/[+\-*\/]{2,}/) + + # No leading or trailing math operators + return false if @roll_command.match?(/\A[+\-*\/]/) || @roll_command.match?(/[+\-*\/]\z/) + + @roll_command.match?( + / + \A( + (\d*d\d*) | + ([+\-*\/]) | + (\d+) | + (self) + )*\z/xi, + ) + end + + private + + def roll_command_parts + @roll_command.scan(/([+\-*\/])|(\d*d\d+)|(\d+)|(self)/xi) + .flatten + .compact_blank + end + + def roll_dice(command) + parts = command.downcase.split("d").compact_blank + die_type = parts.last + dice_number = parts.length > 1 ? parts.first.to_i : 1 + + result = 0 + dice_number.times do + result += rand(1..die_type.to_i) + end + result + end +end diff --git a/test/models/stat_test.rb b/test/models/stat_test.rb index f5e4860..760c6a9 100644 --- a/test/models/stat_test.rb +++ b/test/models/stat_test.rb @@ -22,4 +22,10 @@ class StatTest < ActiveSupport::TestCase assert_not_equal existing_stat.slug, stat.slug assert_equal "#{existing_stat.slug}-2", stat.slug end + + test "rolls with roll_command" do + stat = stats(:strength) + stat.roll_command = "1d6" + 100.times { assert (1..6).cover?(stat.roll) } + end end diff --git a/test/services/dice_roller_test.rb b/test/services/dice_roller_test.rb new file mode 100644 index 0000000..23266fa --- /dev/null +++ b/test/services/dice_roller_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "test_helper" + +class DiceRollerTest < ActiveSupport::TestCase + test "correctly validates strings" do + valid_strings = [ + "2d6", + "12", + "self", + "d12", + "d20+self", + "8+D8-self", + ] + + invalid_strings = [ + "+", + "sel+10", + "d8++13", + ] + + valid_strings.each do |roll_command| + assert DiceRoller.new(roll_command).valid? + end + + invalid_strings.each do |roll_command| + assert_not DiceRoller.new(roll_command).valid? + end + end + + test "rolls appropriate results" do + 100.times do + assert (1..6).include? DiceRoller.new("d6").roll + assert (2..12).include? DiceRoller.new("2d6").roll + assert (11..30).include? DiceRoller.new("d20+self", stat: stats(:strength)).roll + end + end +end diff --git a/todo.md b/todo.md index 59d3bbc..c811f1c 100644 --- a/todo.md +++ b/todo.md @@ -1,5 +1,4 @@ - fix inconsistencies in nested create: do we need to submit the id or use in url? -- add stat stimulus controller to update stats - default avatars - add uuid/slug to characters and any other url-visible ids - shared/private notes