Skip to content
Open
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
22 changes: 22 additions & 0 deletions app/commands/characters_context/avatar_attaching.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module CharactersContext
# Avatar upload branch shared by the provider update commands: at most one of
# avatar_file/avatar_url/file arrives (the contracts enforce it), and a failed
# upload must never fail the update that carried it.
module AvatarAttaching
private

def upload_avatar(input) # rubocop: disable Metrics/AbcSize
return if input.slice(:avatar_file, :avatar_url, :file).keys.blank?

attach_avatar_by_file.call({ character: input[:character], file: input[:avatar_file] }) if input[:avatar_file]
attach_avatar_by_url.call({ character: input[:character], url: input[:avatar_url] }) if input[:avatar_url]
return unless input[:file]

input[:character].avatar.attach(input[:file])
cache.push_item(item: input[:character].avatar)
rescue StandardError => _e
end
end
end
18 changes: 18 additions & 0 deletions app/commands/characters_context/character_options.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true

module CharactersContext
# Enumerations the provider update contracts validate arrays against. The
# dnd5, dnd2024 and TLC rule sets share every list here.
module CharacterOptions
SKILLS = %w[
acrobatics animal arcana athletics deception history insight intimidation investigation
medicine nature perception performance persuasion religion sleight stealth survival
].freeze
WEAPON_CORE_SKILLS = %w[light martial].freeze
ARMOR_PROFICIENCY = %w[light medium heavy shield].freeze
DAMAGE_TYPES = %w[
bludge pierce slash acid cold fire force lighting necrotic
poison psychic radiant thunder
].freeze
end
end
50 changes: 50 additions & 0 deletions app/commands/characters_context/class_spell_feats.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

module CharactersContext
# dnd2024-style spell lists: a spell is a Feat row with origin `class_spell`
# (6), attached to the character and tagged with the class that prepared it, so
# dropping a class can drop exactly the spells it brought. Including commands
# supply the content scope and the homebrew feat types their provider owns.
module ClassSpellFeats
private

def spell_feats_scope = raise(NotImplementedError)
def homebrew_feat_types = raise(NotImplementedError)

def refresh_class_spells(input)
character = input[:character]
input[:added_classes].each do |added_class|
learn_class_spells(character: character, class_name: added_class, user: character.user)
end
input[:removed_classes].each { |removed_class| forget_class_spells(character: character, class_name: removed_class) }
end

def learn_class_spells(character:, class_name:, user:)
return if ::Dnd2024::Character::CLASSES_KNOW_SPELLS_LIST.exclude?(class_name)

relation = spell_feats_scope.where(origin: 6).where('origin_values && ?', "{#{class_name}}")
spells =
relation.where(user_id: [nil, user.id]).or(relation.where(id: homebrew_feat_ids(user)))
.map do |feat|
{
character_id: character.id,
feat_id: feat.id,
ready_to_use: false,
value: { prepared_by: class_name }
}
end
::Character::Feat.upsert_all(spells) if spells.any?
end

def forget_class_spells(character:, class_name:)
character.feats.where("value -> 'prepared_by' ? :prepared_by", prepared_by: class_name).delete_all
end

def homebrew_feat_ids(user)
::Homebrew::Book::Item
.where(homebrew_book_id: ::User::Book.where(user_id: user).select(:homebrew_book_id))
.where(itemable_type: homebrew_feat_types)
.pluck(:itemable_id)
end
end
end
20 changes: 20 additions & 0 deletions app/commands/characters_context/coins_syncing.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

module CharactersContext
# `money` (total copper) and `coins` (gold/silver/copper) are two views of the
# same purse, and a client sends whichever one its screen edits. Shared by the
# provider update commands so both views persist in step.
module CoinsSyncing
private

def sync_coins_and_money(input)
if input.key?(:money)
gold, modulus = input[:money].divmod(100)
silver, copper = modulus.divmod(10)
input[:coins] = { copper: copper, silver: silver, gold: gold }
elsif input.key?(:coins)
input[:money] = (input.dig(:coins, :gold) * 100) + (input.dig(:coins, :silver) * 10) + input.dig(:coins, :copper)
end
end
end
end
67 changes: 9 additions & 58 deletions app/commands/characters_context/dnd2024/update_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,17 @@
module CharactersContext
module Dnd2024
class UpdateCommand < BaseCommand
include CharactersContext::AvatarAttaching
include CharactersContext::CharacterOptions
include CharactersContext::ClassSpellFeats
include CharactersContext::CoinsSyncing
include Deps[
attach_avatar_by_url: 'commands.image_processing.attach_avatar_by_url',
attach_avatar_by_file: 'commands.image_processing.attach_avatar_by_file',
refresh_feats: 'services.characters_context.dnd2024.refresh_feats',
cache: 'cache.avatars'
]

SKILLS = %w[
acrobatics animal arcana athletics deception history insight intimidation investigation
medicine nature perception performance persuasion religion sleight stealth survival
].freeze
WEAPON_CORE_SKILLS = %w[light martial].freeze
ARMOR_PROFICIENCY = %w[light medium heavy shield].freeze
DAMAGE_TYPES = %w[bludge pierce slash acid cold fire force lighting necrotic poison psychic radiant thunder].freeze

# rubocop: disable Metrics/BlockLength
use_contract do
config.messages.namespace = :dnd5_character
Expand Down Expand Up @@ -103,7 +99,7 @@ class UpdateCommand < BaseCommand
def lock_key(input) = "character_update_#{input[:character].id}"
def lock_time = 0

def do_prepare(input) # rubocop: disable Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/MethodLength
def do_prepare(input) # rubocop: disable Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/MethodLength
%i[classes abilities health coins energy spent_spell_slots spent_hit_dice].each do |key|
input[key]&.transform_values!(&:to_i)
end
Expand All @@ -118,13 +114,7 @@ def do_prepare(input) # rubocop: disable Metrics/AbcSize, Metrics/PerceivedCompl
end
end

if input.key?(:money)
gold, modulus = input[:money].divmod(100)
silver, copper = modulus.divmod(10)
input[:coins] = { copper: copper, silver: silver, gold: gold }
elsif input.key?(:coins)
input[:money] = (input.dig(:coins, :gold) * 100) + (input.dig(:coins, :silver) * 10) + input.dig(:coins, :copper)
end
sync_coins_and_money(input)

if input.key?(:abilities)
input[:ability_boosts] = 0
Expand All @@ -149,53 +139,14 @@ def do_persist(input) # rubocop: disable Metrics/AbcSize
if %i[classes subclasses selected_features selected_feats].intersect?(input.keys)
refresh_feats.call(character: input[:character])
end
refresh_spells(input) if input[:classes]
refresh_class_spells(input) if input[:classes]
upload_avatar(input)

{ result: input[:character] }
end

def refresh_spells(input) # rubocop: disable Metrics/AbcSize
input[:added_classes].each do |added_class|
next if ::Dnd2024::Character::CLASSES_KNOW_SPELLS_LIST.exclude?(added_class)

relation = ::Dnd2024::Feat.where(origin: 6).where('origin_values && ?', "{#{added_class}}")
spells =
relation.where(user_id: [nil, input[:character].user_id]).or(relation.where(id: homebrew_item_ids(input)))
.map do |feat|
{
character_id: input[:character].id,
feat_id: feat.id,
ready_to_use: false,
value: { prepared_by: added_class }
}
end
::Character::Feat.upsert_all(spells) if spells.any?
end

input[:removed_classes].each do |removed_class|
input[:character].feats.where("value -> 'prepared_by' ? :prepared_by", prepared_by: removed_class).delete_all
end
end

def homebrew_item_ids(input)
::Homebrew::Book::Item
.where(homebrew_book_id: ::User::Book.where(user_id: input[:character].user).select(:homebrew_book_id))
.where(itemable_type: 'Dnd2024::Feat')
.pluck(:itemable_id)
end

def upload_avatar(input) # rubocop: disable Metrics/AbcSize
return if input.slice(:avatar_file, :avatar_url, :file).keys.blank?

attach_avatar_by_file.call({ character: input[:character], file: input[:avatar_file] }) if input[:avatar_file]
attach_avatar_by_url.call({ character: input[:character], url: input[:avatar_url] }) if input[:avatar_url]
return unless input[:file]

input[:character].avatar.attach(input[:file])
cache.push_item(item: input[:character].avatar)
rescue StandardError => _e
end
def spell_feats_scope = ::Dnd2024::Feat
def homebrew_feat_types = 'Dnd2024::Feat'
end
end
end
38 changes: 6 additions & 32 deletions app/commands/characters_context/dnd5/update_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,16 @@
module CharactersContext
module Dnd5
class UpdateCommand < BaseCommand
include CharactersContext::AvatarAttaching
include CharactersContext::CharacterOptions
include CharactersContext::CoinsSyncing
include Deps[
attach_avatar_by_url: 'commands.image_processing.attach_avatar_by_url',
attach_avatar_by_file: 'commands.image_processing.attach_avatar_by_file',
refresh_feats: 'services.characters_context.dnd5.refresh_feats',
cache: 'cache.avatars'
]

SKILLS = %w[
acrobatics animal arcana athletics deception history insight intimidation investigation
medicine nature perception performance persuasion religion sleight stealth survival
].freeze
WEAPON_CORE_SKILLS = %w[light martial].freeze
ARMOR_PROFICIENCY = %w[light medium heavy shield].freeze
DAMAGE_TYPES = %w[
bludge pierce slash acid cold fire force lighting necrotic
poison psychic radiant thunder
].freeze

# rubocop: disable Metrics/BlockLength
use_contract do
config.messages.namespace = :dnd5_character
Expand Down Expand Up @@ -104,7 +96,7 @@ class UpdateCommand < BaseCommand
def lock_key(input) = "character_update_#{input[:character].id}"
def lock_time = 0

# rubocop: disable Metrics/AbcSize, Metrics/PerceivedComplexity
# rubocop: disable Metrics/AbcSize
def do_prepare(input)
if input[:classes]
input[:level] = input[:classes].values.sum(&:to_i)
Expand All @@ -122,13 +114,7 @@ def do_prepare(input)
input[:hit_dice][::Dnd5::Character::HIT_DICES[key]] += class_level
end

if input.key?(:money)
gold, modulus = input[:money].divmod(100)
silver, copper = modulus.divmod(10)
input[:coins] = { copper: copper, silver: silver, gold: gold }
elsif input.key?(:coins)
input[:money] = (input.dig(:coins, :gold) * 100) + (input.dig(:coins, :silver) * 10) + input.dig(:coins, :copper)
end
sync_coins_and_money(input)
end

def do_persist(input)
Expand All @@ -145,7 +131,7 @@ def do_persist(input)

{ result: input[:character] }
end
# rubocop: enable Metrics/AbcSize, Metrics/PerceivedComplexity
# rubocop: enable Metrics/AbcSize

def refresh_spells(input)
input[:added_classes].each do |added_class|
Expand All @@ -166,18 +152,6 @@ def refresh_spells(input)
input[:character].spells.where("data -> 'prepared_by' ? :prepared_by", prepared_by: removed_class).delete_all
end
end

def upload_avatar(input) # rubocop: disable Metrics/AbcSize
return if input.slice(:avatar_file, :avatar_url, :file).keys.blank?

attach_avatar_by_file.call({ character: input[:character], file: input[:avatar_file] }) if input[:avatar_file]
attach_avatar_by_url.call({ character: input[:character], url: input[:avatar_url] }) if input[:avatar_url]
return unless input[:file]

input[:character].avatar.attach(input[:file])
cache.push_item(item: input[:character].avatar)
rescue StandardError => _e
end
end
end
end
66 changes: 9 additions & 57 deletions app/commands/characters_context/tlc/update_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,17 @@ module Tlc
# eval_variables / description_eval_variables (Ruby-eval'd feat columns —
# plan §Security T4). Config-derived enums read the dnd2024 baseline (plan P4).
class UpdateCommand < BaseCommand
include CharactersContext::AvatarAttaching
include CharactersContext::CharacterOptions
include CharactersContext::ClassSpellFeats
include CharactersContext::CoinsSyncing
include Deps[
attach_avatar_by_url: 'commands.image_processing.attach_avatar_by_url',
attach_avatar_by_file: 'commands.image_processing.attach_avatar_by_file',
refresh_feats: 'services.characters_context.tlc.refresh_feats',
cache: 'cache.avatars'
]

SKILLS = %w[
acrobatics animal arcana athletics deception history insight intimidation investigation
medicine nature perception performance persuasion religion sleight stealth survival
].freeze
WEAPON_CORE_SKILLS = %w[light martial].freeze
ARMOR_PROFICIENCY = %w[light medium heavy shield].freeze
DAMAGE_TYPES = %w[bludge pierce slash acid cold fire force lighting necrotic poison psychic radiant thunder].freeze

# plan §Security threats 2/3: bound + dedupe the JSONB trait array.
SELECTED_TRAITS_CAP = 10

Expand Down Expand Up @@ -168,13 +164,7 @@ def do_prepare(input) # rubocop: disable Metrics/AbcSize, Metrics/PerceivedCompl
end
end

if input.key?(:money)
gold, modulus = input[:money].divmod(100)
silver, copper = modulus.divmod(10)
input[:coins] = { copper: copper, silver: silver, gold: gold }
elsif input.key?(:coins)
input[:money] = (input.dig(:coins, :gold) * 100) + (input.dig(:coins, :silver) * 10) + input.dig(:coins, :copper)
end
sync_coins_and_money(input)

if input.key?(:abilities)
input[:ability_boosts] = 0
Expand All @@ -199,53 +189,15 @@ def do_persist(input) # rubocop: disable Metrics/AbcSize
if %i[classes subclasses selected_features selected_feats].intersect?(input.keys)
refresh_feats.call(character: input[:character])
end
refresh_spells(input) if input[:classes]
refresh_class_spells(input) if input[:classes]
upload_avatar(input)

{ result: input[:character] }
end

def refresh_spells(input) # rubocop: disable Metrics/AbcSize
input[:added_classes].each do |added_class|
next if ::Dnd2024::Character::CLASSES_KNOW_SPELLS_LIST.exclude?(added_class)

relation = ::Feat.tlc_content.where(origin: 6).where('origin_values && ?', "{#{added_class}}")
spells =
relation.where(user_id: [nil, input[:character].user_id]).or(relation.where(id: homebrew_item_ids(input)))
.map do |feat|
{
character_id: input[:character].id,
feat_id: feat.id,
ready_to_use: false,
value: { prepared_by: added_class }
}
end
::Character::Feat.upsert_all(spells) if spells.any?
end

input[:removed_classes].each do |removed_class|
input[:character].feats.where("value -> 'prepared_by' ? :prepared_by", prepared_by: removed_class).delete_all
end
end

def homebrew_item_ids(input)
::Homebrew::Book::Item
.where(homebrew_book_id: ::User::Book.where(user_id: input[:character].user).select(:homebrew_book_id))
.where(itemable_type: %w[Dnd2024::Feat Tlc::Feat])
.pluck(:itemable_id)
end

def upload_avatar(input) # rubocop: disable Metrics/AbcSize
return if input.slice(:avatar_file, :avatar_url, :file).keys.blank?

attach_avatar_by_file.call({ character: input[:character], file: input[:avatar_file] }) if input[:avatar_file]
attach_avatar_by_url.call({ character: input[:character], url: input[:avatar_url] }) if input[:avatar_url]
return unless input[:file]

input[:character].avatar.attach(input[:file])
cache.push_item(item: input[:character].avatar)
rescue StandardError => _e
end
# TLC reads the union content scope, and owns both dnd2024 and tlc homebrew feats.
def spell_feats_scope = ::Feat.tlc_content
def homebrew_feat_types = %w[Dnd2024::Feat Tlc::Feat]
end
end
end
Loading