diff --git a/app/model/rollmaster/roll.rb b/app/model/rollmaster/roll.rb new file mode 100644 index 0000000..2396146 --- /dev/null +++ b/app/model/rollmaster/roll.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ::Rollmaster + class Roll < ActiveRecord::Base + self.table_name = "rollmaster_rolls" + + # let Post to Roll association occur via the cooked text. + # Roll to Post association will be explicit for backtracking (i.e. auditing). + belongs_to :post + validates :raw, presence: true + validates :notation, presence: true + validates :result, presence: true + end +end + +# == Schema Information +# +# Table name: rollmaster_rolls +# +# id :bigint not null, primary key +# post_id :integer +# raw :string +# notation :string +# result :string +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# index_rollmaster_rolls_on_post_id (post_id) diff --git a/db/.gitkeep b/db/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/db/migrate/20250811165607_create_rollmaster_rolls.rb b/db/migrate/20250811165607_create_rollmaster_rolls.rb new file mode 100644 index 0000000..c472740 --- /dev/null +++ b/db/migrate/20250811165607_create_rollmaster_rolls.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateRollmasterRolls < ActiveRecord::Migration[7.2] + def change + create_table :rollmaster_rolls do |t| + t.integer :post_id + t.string :raw + t.string :notation + t.string :result + + t.timestamps + end + add_index :rollmaster_rolls, :post_id + end +end diff --git a/lib/rollmaster/handle_cooked_post_process.rb b/lib/rollmaster/handle_cooked_post_process.rb index b3ea412..b63cef7 100644 --- a/lib/rollmaster/handle_cooked_post_process.rb +++ b/lib/rollmaster/handle_cooked_post_process.rb @@ -1,15 +1,116 @@ # frozen_string_literal: true module ::Rollmaster + SELECTOR_QUERY = ".bb-rollmaster[data-notation]" + class HandleCookedPostProcess def self.process(doc, post) # Add your processing logic here - # parse the post content + # { raw, dom }[] + roll_elements = [] + + # parse the post content for rolls and flatten + doc + .css(SELECTOR_QUERY) + .each do |roll_element| + original_notation = roll_element.attribute("data-notation").value + + next if original_notation.blank? + + original_notation + .split(/\n/) + .each do |notation| + if notation.strip.empty? + roll_elements << { raw: "", dom: roll_element } # let us keep empty lines + else + roll_elements << { raw: notation, dom: roll_element } + end + end + roll_element.content = "" # clear the original notation + end + + return if roll_elements.empty? + + # { raw, dom, formatted, result, error }[] + roll_elements.each do |element| + notation = element[:raw] + next if notation.blank? + + element.merge!(process_roll(notation)) + end + + # { raw, dom, formatted, result, error, id }[] + match_rolls(roll_elements, post) if post.id? + save_rolls(roll_elements, post) - # check for existing dice rolls associated with post + roll_elements + .group_by { |e| e[:dom] } + .each do |dom, rolls| + content = + rolls.map do |e| + if e[:error] + e[:raw] + elsif e[:raw].empty? + "" + else + e[:raw] + ": " + e[:result] # TODO: consider decorating with spans + end + end + dom.content = CGI.unescapeHTML(content.join("\n")) + dom["data-roll-id"] = rolls.map { |e| e[:id] }.join(",") if rolls.any? { |e| e[:id] } + end + + true + end + + def self.process_roll(notation) + begin + formatted = Rollmaster::DiceEngine.format_notation(notation).first + final = Rollmaster::DiceEngine.roll(notation).first + { error: false, formatted: formatted, result: final } + rescue Rollmaster::DiceEngine::RollError => e + Rails.logger.warn("Rollmaster: Error formatting notation for post #{post.id}: #{e.message}") + { error: true, formatted: nil, result: e.message } + end + end + + def self.match_rolls(rolls, post) + existing_rolls = Rollmaster::Roll.where(post_id: post.id).to_a + return if existing_rolls.empty? + + rolls + .reject { |r| r[:raw].empty? || r[:error] } + .each do |roll| + existing_roll_idx = + existing_rolls.index { |r| r.raw == roll[:raw] || r.notation == roll[:formatted] } + next if existing_roll_idx.nil? + + existing_roll = existing_rolls[existing_roll_idx] + roll[:id] = existing_roll.id + roll[:result] = existing_roll.result # use existing roll result + existing_rolls.delete_at(existing_roll_idx) + end + end - # create new dice rolls for any new ones + def self.save_rolls(rolls, post) + rolls + .reject { |r| r[:raw].empty? || r[:error] } + .each do |roll| + if roll[:id] + existing_roll = Rollmaster::Roll.find(roll[:id]) + existing_roll.update!(raw: roll[:raw], notation: roll[:formatted]) + else + new_roll = + Rollmaster::Roll.create!( + post_id: post.id, + raw: roll[:raw], + notation: roll[:formatted], + result: roll[:result], + ) + roll[:id] = new_roll.id + end + end end end end diff --git a/plugin.rb b/plugin.rb index bad6c33..9d86e74 100644 --- a/plugin.rb +++ b/plugin.rb @@ -26,6 +26,9 @@ module ::Rollmaster # I don't think this is needed, but it doesn't hurt to be safe ::Rollmaster::DiceEngine.reset_context - on(:post_process_cooked) { |doc, post| ::Rollmaster::HandleCookedPostProcess.process(doc, post) } + on(:before_post_process_cooked) do |doc, post| + ::Rollmaster::HandleCookedPostProcess.process(doc, post) if SiteSetting.rollmaster_enabled + end + # TODO: consider :chat_message_processed as well end diff --git a/spec/integration/bbcode_spec.rb b/spec/integration/bbcode_spec.rb new file mode 100644 index 0000000..75a3e14 --- /dev/null +++ b/spec/integration/bbcode_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +RSpec.describe "Rollmaster BBCode integration", type: :integration do + before do + enable_current_plugin + Jobs.run_immediately! + end + + it "processes [roll] BBCode and creates a Roll record" do + post = Fabricate(:post, raw: <<~MD) + [roll]2d6[/roll] + MD + post.save + cpp = CookedPostProcessor.new(post) + cpp.post_process + + roll = ::Rollmaster::Roll.find_by(post_id: post.id) + + expect(roll).not_to be_nil + expect(roll.post_id).to eq(post.id) + expect(roll.raw).to eq("2d6") + expect(cpp.html).to include("data-roll-id=\"#{roll.id}\"") + end + + it "handles multiple [roll] BBCode in a single post" do + post = Fabricate(:post, raw: <<~MD) + Here are some rolls: + [roll]1d20[/roll] + [roll]3d8+2[/roll] + [roll]4d6kh3[/roll] + MD + post.save + cpp = CookedPostProcessor.new(post) + cpp.post_process + + rolls = ::Rollmaster::Roll.where(post_id: post.id).to_a + + expect(rolls.size).to eq(3) + expect(rolls.map(&:raw)).to contain_exactly("1d20", "3d8+2", "4d6kh3") + rolls.each { |roll| expect(cpp.html).to include("data-roll-id=\"#{roll.id}\"") } + end + + it "reuses existing rolls when a post is edited" do + post = Fabricate(:post, raw: <<~MD) + Initial roll: [roll]1d6[/roll] + MD + post.save + cpp = CookedPostProcessor.new(post) + cpp.post_process + + initial_roll = ::Rollmaster::Roll.find_by(post_id: post.id) + expect(initial_roll).not_to be_nil + expect(initial_roll.raw).to eq("1d6") + + # Edit the post to change the roll + post.raw = <<~MD + Updated rolls: + [roll]1d6[/roll] + [roll]1d4+1[/roll] + MD + post.save + cpp = CookedPostProcessor.new(post) + cpp.post_process + + rolls = ::Rollmaster::Roll.where(post_id: post.id).to_a + expect(rolls.size).to eq(2) + expect(rolls.map(&:raw)).to contain_exactly("1d6", "1d4+1") + expect(initial_roll.id).in?(rolls.map(&:id)) + rolls.each { |roll| expect(cpp.html).to include("data-roll-id=\"#{roll.id}\"") } + end +end