Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions app/model/rollmaster/roll.rb
Original file line number Diff line number Diff line change
@@ -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)
Empty file removed db/.gitkeep
Empty file.
15 changes: 15 additions & 0 deletions db/migrate/20250811165607_create_rollmaster_rolls.rb
Original file line number Diff line number Diff line change
@@ -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
107 changes: 104 additions & 3 deletions lib/rollmaster/handle_cooked_post_process.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion plugin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
71 changes: 71 additions & 0 deletions spec/integration/bbcode_spec.rb
Original file line number Diff line number Diff line change
@@ -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