diff --git a/app/commands/characters_context/avatar_attaching.rb b/app/commands/characters_context/avatar_attaching.rb new file mode 100644 index 000000000..62cfc16f5 --- /dev/null +++ b/app/commands/characters_context/avatar_attaching.rb @@ -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 diff --git a/app/commands/characters_context/character_options.rb b/app/commands/characters_context/character_options.rb new file mode 100644 index 000000000..fc982f1d0 --- /dev/null +++ b/app/commands/characters_context/character_options.rb @@ -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 diff --git a/app/commands/characters_context/class_spell_feats.rb b/app/commands/characters_context/class_spell_feats.rb new file mode 100644 index 000000000..40bb4536e --- /dev/null +++ b/app/commands/characters_context/class_spell_feats.rb @@ -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 diff --git a/app/commands/characters_context/coins_syncing.rb b/app/commands/characters_context/coins_syncing.rb new file mode 100644 index 000000000..ab93685ab --- /dev/null +++ b/app/commands/characters_context/coins_syncing.rb @@ -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 diff --git a/app/commands/characters_context/dnd2024/update_command.rb b/app/commands/characters_context/dnd2024/update_command.rb index 29cbcf26d..d61922bd2 100644 --- a/app/commands/characters_context/dnd2024/update_command.rb +++ b/app/commands/characters_context/dnd2024/update_command.rb @@ -3,6 +3,10 @@ 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', @@ -10,14 +14,6 @@ class UpdateCommand < BaseCommand 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 @@ -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 @@ -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 @@ -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 diff --git a/app/commands/characters_context/dnd5/update_command.rb b/app/commands/characters_context/dnd5/update_command.rb index fbfc7deb3..802e4895c 100644 --- a/app/commands/characters_context/dnd5/update_command.rb +++ b/app/commands/characters_context/dnd5/update_command.rb @@ -3,6 +3,9 @@ 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', @@ -10,17 +13,6 @@ class UpdateCommand < BaseCommand 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 @@ -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) @@ -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) @@ -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| @@ -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 diff --git a/app/commands/characters_context/tlc/update_command.rb b/app/commands/characters_context/tlc/update_command.rb index 234e52c28..3ae64b37c 100644 --- a/app/commands/characters_context/tlc/update_command.rb +++ b/app/commands/characters_context/tlc/update_command.rb @@ -9,6 +9,10 @@ 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', @@ -16,14 +20,6 @@ class UpdateCommand < BaseCommand 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 @@ -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 @@ -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 diff --git a/app/decorators/dnd5_character/class_decorate_wrapper.rb b/app/decorators/dnd5_character/class_decorate_wrapper.rb index 1eee8c7c9..993628a66 100644 --- a/app/decorators/dnd5_character/class_decorate_wrapper.rb +++ b/app/decorators/dnd5_character/class_decorate_wrapper.rb @@ -2,28 +2,7 @@ module Dnd5Character class ClassDecorateWrapper < ApplicationDecorateWrapper - SPELL_SLOTS = { - 1 => { 1 => 2 }, - 2 => { 1 => 3 }, - 3 => { 1 => 4, 2 => 2 }, - 4 => { 1 => 4, 2 => 3 }, - 5 => { 1 => 4, 2 => 3, 3 => 2 }, - 6 => { 1 => 4, 2 => 3, 3 => 3 }, - 7 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 }, - 8 => { 1 => 4, 2 => 3, 3 => 3, 4 => 2 }, - 9 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 1 }, - 10 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2 }, - 11 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1 }, - 12 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1 }, - 13 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1, 7 => 1 }, - 14 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1, 7 => 1 }, - 15 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1, 7 => 1, 8 => 1 }, - 16 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1, 7 => 1, 8 => 1 }, - 17 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1, 7 => 1, 8 => 1, 9 => 1 }, - 18 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 3, 6 => 1, 7 => 1, 8 => 1, 9 => 1 }, - 19 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 3, 6 => 2, 7 => 1, 8 => 1, 9 => 1 }, - 20 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 3, 6 => 2, 7 => 2, 8 => 1, 9 => 1 } - }.freeze + SPELL_SLOTS = SpellSlots::FULL_CASTER def spells_slots @spells_slots ||= diff --git a/app/decorators/dnd5_character/classes/artificer_decorator.rb b/app/decorators/dnd5_character/classes/artificer_decorator.rb index 9c9d72b31..1d03c8e12 100644 --- a/app/decorators/dnd5_character/classes/artificer_decorator.rb +++ b/app/decorators/dnd5_character/classes/artificer_decorator.rb @@ -3,28 +3,7 @@ module Dnd5Character module Classes class ArtificerDecorator < ApplicationDecorator - SPELL_SLOTS = { - 1 => { 1 => 2 }, - 2 => { 1 => 2 }, - 3 => { 1 => 3 }, - 4 => { 1 => 3 }, - 5 => { 1 => 4, 2 => 2 }, - 6 => { 1 => 4, 2 => 2 }, - 7 => { 1 => 4, 2 => 3 }, - 8 => { 1 => 4, 2 => 3 }, - 9 => { 1 => 4, 2 => 3, 3 => 2 }, - 10 => { 1 => 4, 2 => 3, 3 => 2 }, - 11 => { 1 => 4, 2 => 3, 3 => 3 }, - 12 => { 1 => 4, 2 => 3, 3 => 3 }, - 13 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 }, - 14 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 }, - 15 => { 1 => 4, 2 => 3, 3 => 3, 4 => 2 }, - 16 => { 1 => 4, 2 => 3, 3 => 3, 4 => 2 }, - 17 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 1 }, - 18 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 1 }, - 19 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2 }, - 20 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2 } - }.freeze + SPELL_SLOTS = SpellSlots::HALF_CASTER CLASS_SAVE_DC = %w[con int].freeze def class_save_dc diff --git a/app/decorators/dnd5_character/classes/bard_decorator.rb b/app/decorators/dnd5_character/classes/bard_decorator.rb index 9b83b9c8c..47f342545 100644 --- a/app/decorators/dnd5_character/classes/bard_decorator.rb +++ b/app/decorators/dnd5_character/classes/bard_decorator.rb @@ -26,7 +26,7 @@ def spell_classes end def spells_slots - @spells_slots ||= ::Dnd5Character::ClassDecorateWrapper::SPELL_SLOTS[class_level] + @spells_slots ||= SpellSlots::FULL_CASTER[class_level] end private @@ -55,7 +55,7 @@ def spells_amount end def max_spell_level - ::Dnd5Character::ClassDecorateWrapper::SPELL_SLOTS[class_level].keys.max + SpellSlots::FULL_CASTER[class_level].keys.max end end end diff --git a/app/decorators/dnd5_character/classes/cleric_decorator.rb b/app/decorators/dnd5_character/classes/cleric_decorator.rb index f5c3867b2..0e0bfa2f5 100644 --- a/app/decorators/dnd5_character/classes/cleric_decorator.rb +++ b/app/decorators/dnd5_character/classes/cleric_decorator.rb @@ -27,7 +27,7 @@ def spell_classes # rubocop: enable Metrics/AbcSize def spells_slots - @spells_slots ||= ::Dnd5Character::ClassDecorateWrapper::SPELL_SLOTS[class_level] + @spells_slots ||= SpellSlots::FULL_CASTER[class_level] end private @@ -44,7 +44,7 @@ def cantrips_amount end def max_spell_level - ::Dnd5Character::ClassDecorateWrapper::SPELL_SLOTS[class_level].keys.max + SpellSlots::FULL_CASTER[class_level].keys.max end end end diff --git a/app/decorators/dnd5_character/classes/druid_decorator.rb b/app/decorators/dnd5_character/classes/druid_decorator.rb index 01916e96c..ddfb02aba 100644 --- a/app/decorators/dnd5_character/classes/druid_decorator.rb +++ b/app/decorators/dnd5_character/classes/druid_decorator.rb @@ -27,7 +27,7 @@ def spell_classes # rubocop: enable Metrics/AbcSize def spells_slots - @spells_slots ||= ::Dnd5Character::ClassDecorateWrapper::SPELL_SLOTS[class_level] + @spells_slots ||= SpellSlots::FULL_CASTER[class_level] end private @@ -44,7 +44,7 @@ def cantrips_amount end def max_spell_level - ::Dnd5Character::ClassDecorateWrapper::SPELL_SLOTS[class_level].keys.max + SpellSlots::FULL_CASTER[class_level].keys.max end end end diff --git a/app/decorators/dnd5_character/classes/sorcerer_decorator.rb b/app/decorators/dnd5_character/classes/sorcerer_decorator.rb index 34557d72d..7d4cca158 100644 --- a/app/decorators/dnd5_character/classes/sorcerer_decorator.rb +++ b/app/decorators/dnd5_character/classes/sorcerer_decorator.rb @@ -26,7 +26,7 @@ def spell_classes end def spells_slots - @spells_slots ||= ::Dnd5Character::ClassDecorateWrapper::SPELL_SLOTS[class_level] + @spells_slots ||= SpellSlots::FULL_CASTER[class_level] end private @@ -52,7 +52,7 @@ def spells_amount end def max_spell_level - ::Dnd5Character::ClassDecorateWrapper::SPELL_SLOTS[class_level].keys.max + SpellSlots::FULL_CASTER[class_level].keys.max end end end diff --git a/app/decorators/dnd5_character/classes/warlock_decorator.rb b/app/decorators/dnd5_character/classes/warlock_decorator.rb index 3797491c4..090a46dc5 100644 --- a/app/decorators/dnd5_character/classes/warlock_decorator.rb +++ b/app/decorators/dnd5_character/classes/warlock_decorator.rb @@ -3,28 +3,7 @@ module Dnd5Character module Classes class WarlockDecorator < ApplicationDecorator - SPELL_SLOTS = { - 1 => { 1 => 1 }, - 2 => { 1 => 2 }, - 3 => { 1 => 0, 2 => 2 }, - 4 => { 1 => 0, 2 => 2 }, - 5 => { 1 => 0, 2 => 0, 3 => 2 }, - 6 => { 1 => 0, 2 => 0, 3 => 2 }, - 7 => { 1 => 0, 2 => 0, 3 => 0, 4 => 2 }, - 8 => { 1 => 0, 2 => 0, 3 => 0, 4 => 2 }, - 9 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 2 }, - 10 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 2 }, - 11 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0 }, - 12 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0 }, - 13 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0, 7 => 0 }, - 14 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0, 7 => 0 }, - 15 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0, 7 => 0, 8 => 0 }, - 16 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0, 7 => 0, 8 => 0 }, - 17 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 4, 6 => 0, 7 => 0, 8 => 0, 9 => 0 }, - 18 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 4, 6 => 0, 7 => 0, 8 => 0, 9 => 0 }, - 19 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 4, 6 => 0, 7 => 0, 8 => 0, 9 => 0 }, - 20 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 4, 6 => 0, 7 => 0, 8 => 0, 9 => 0 } - }.freeze + SPELL_SLOTS = SpellSlots::WARLOCK CLASS_SAVE_DC = %w[wis cha].freeze def class_save_dc diff --git a/app/decorators/dnd5_character/classes/wizard_decorator.rb b/app/decorators/dnd5_character/classes/wizard_decorator.rb index af3f0d329..6cbb4ba3d 100644 --- a/app/decorators/dnd5_character/classes/wizard_decorator.rb +++ b/app/decorators/dnd5_character/classes/wizard_decorator.rb @@ -27,7 +27,7 @@ def spell_classes # rubocop: enable Metrics/AbcSize def spells_slots - @spells_slots ||= ::Dnd5Character::ClassDecorateWrapper::SPELL_SLOTS[class_level] + @spells_slots ||= SpellSlots::FULL_CASTER[class_level] end private @@ -44,7 +44,7 @@ def cantrips_amount end def max_spell_level - ::Dnd5Character::ClassDecorateWrapper::SPELL_SLOTS[class_level].keys.max + SpellSlots::FULL_CASTER[class_level].keys.max end end end diff --git a/app/decorators_v2/dnd2024/classes/artificer_decorator.rb b/app/decorators_v2/dnd2024/classes/artificer_decorator.rb index 291329c27..1ce787878 100644 --- a/app/decorators_v2/dnd2024/classes/artificer_decorator.rb +++ b/app/decorators_v2/dnd2024/classes/artificer_decorator.rb @@ -3,28 +3,7 @@ module Dnd2024 module Classes class ArtificerDecorator < ApplicationDecoratorV2 - SPELL_SLOTS = { - 1 => { 1 => 2 }, - 2 => { 1 => 2 }, - 3 => { 1 => 3 }, - 4 => { 1 => 3 }, - 5 => { 1 => 4, 2 => 2 }, - 6 => { 1 => 4, 2 => 2 }, - 7 => { 1 => 4, 2 => 3 }, - 8 => { 1 => 4, 2 => 3 }, - 9 => { 1 => 4, 2 => 3, 3 => 2 }, - 10 => { 1 => 4, 2 => 3, 3 => 2 }, - 11 => { 1 => 4, 2 => 3, 3 => 3 }, - 12 => { 1 => 4, 2 => 3, 3 => 3 }, - 13 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 }, - 14 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 }, - 15 => { 1 => 4, 2 => 3, 3 => 3, 4 => 2 }, - 16 => { 1 => 4, 2 => 3, 3 => 3, 4 => 2 }, - 17 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 1 }, - 18 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 1 }, - 19 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2 }, - 20 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2 } - }.freeze + SPELL_SLOTS = SpellSlots::HALF_CASTER CLASS_SAVE_DC = %w[con int].freeze def call(result:) diff --git a/app/decorators_v2/dnd2024/classes/bard_decorator.rb b/app/decorators_v2/dnd2024/classes/bard_decorator.rb index 23942b175..886b6f606 100644 --- a/app/decorators_v2/dnd2024/classes/bard_decorator.rb +++ b/app/decorators_v2/dnd2024/classes/bard_decorator.rb @@ -28,7 +28,7 @@ def spell_class_info end def spells_slots - ::Dnd2024::SubclassDecorator::SPELL_SLOTS[class_level] || ::Dnd2024::SubclassDecorator::SPELL_SLOTS[20] + SpellSlots::FULL_CASTER[class_level] || SpellSlots::FULL_CASTER[20] end def static_spells @@ -61,7 +61,7 @@ def prepared_spells_amount end def max_spell_level - ::Dnd2024::SubclassDecorator::SPELL_SLOTS[class_level].keys.max + SpellSlots::FULL_CASTER[class_level].keys.max end def static_spell_attributes diff --git a/app/decorators_v2/dnd2024/classes/cleric_decorator.rb b/app/decorators_v2/dnd2024/classes/cleric_decorator.rb index 24a96266b..48f9e23c2 100644 --- a/app/decorators_v2/dnd2024/classes/cleric_decorator.rb +++ b/app/decorators_v2/dnd2024/classes/cleric_decorator.rb @@ -28,7 +28,7 @@ def spell_class_info end def spells_slots - ::Dnd2024::SubclassDecorator::SPELL_SLOTS[class_level] || ::Dnd2024::SubclassDecorator::SPELL_SLOTS[20] + SpellSlots::FULL_CASTER[class_level] || SpellSlots::FULL_CASTER[20] end def class_level @@ -54,7 +54,7 @@ def prepared_spells_amount end def max_spell_level - ::Dnd2024::SubclassDecorator::SPELL_SLOTS[class_level].keys.max + SpellSlots::FULL_CASTER[class_level].keys.max end end end diff --git a/app/decorators_v2/dnd2024/classes/druid_decorator.rb b/app/decorators_v2/dnd2024/classes/druid_decorator.rb index 9eb68d877..22e5c06ec 100644 --- a/app/decorators_v2/dnd2024/classes/druid_decorator.rb +++ b/app/decorators_v2/dnd2024/classes/druid_decorator.rb @@ -28,7 +28,7 @@ def spell_class_info end def spells_slots - ::Dnd2024::SubclassDecorator::SPELL_SLOTS[class_level] || ::Dnd2024::SubclassDecorator::SPELL_SLOTS[20] + SpellSlots::FULL_CASTER[class_level] || SpellSlots::FULL_CASTER[20] end def find_static_spells @@ -61,7 +61,7 @@ def prepared_spells_amount end def max_spell_level - ::Dnd2024::SubclassDecorator::SPELL_SLOTS[class_level].keys.max + SpellSlots::FULL_CASTER[class_level].keys.max end end end diff --git a/app/decorators_v2/dnd2024/classes/paladin_decorator.rb b/app/decorators_v2/dnd2024/classes/paladin_decorator.rb index 573c29982..5e853e7b7 100644 --- a/app/decorators_v2/dnd2024/classes/paladin_decorator.rb +++ b/app/decorators_v2/dnd2024/classes/paladin_decorator.rb @@ -3,28 +3,7 @@ module Dnd2024 module Classes class PaladinDecorator < ApplicationDecoratorV2 - SPELL_SLOTS = { - 1 => { 1 => 2 }, - 2 => { 1 => 2 }, - 3 => { 1 => 3 }, - 4 => { 1 => 3 }, - 5 => { 1 => 4, 2 => 2 }, - 6 => { 1 => 4, 2 => 2 }, - 7 => { 1 => 4, 2 => 3 }, - 8 => { 1 => 4, 2 => 3 }, - 9 => { 1 => 4, 2 => 3, 3 => 2 }, - 10 => { 1 => 4, 2 => 3, 3 => 2 }, - 11 => { 1 => 4, 2 => 3, 3 => 3 }, - 12 => { 1 => 4, 2 => 3, 3 => 3 }, - 13 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 }, - 14 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 }, - 15 => { 1 => 4, 2 => 3, 3 => 3, 4 => 2 }, - 16 => { 1 => 4, 2 => 3, 3 => 3, 4 => 2 }, - 17 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 1 }, - 18 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 1 }, - 19 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2 }, - 20 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2 } - }.freeze + SPELL_SLOTS = SpellSlots::HALF_CASTER CLASS_SAVE_DC = %w[wis cha].freeze def call(result:) diff --git a/app/decorators_v2/dnd2024/classes/sorcerer_decorator.rb b/app/decorators_v2/dnd2024/classes/sorcerer_decorator.rb index 76955f4c3..8707d1d2f 100644 --- a/app/decorators_v2/dnd2024/classes/sorcerer_decorator.rb +++ b/app/decorators_v2/dnd2024/classes/sorcerer_decorator.rb @@ -28,7 +28,7 @@ def find_static_spells end def spells_slots - ::Dnd2024::SubclassDecorator::SPELL_SLOTS[class_level] || ::Dnd2024::SubclassDecorator::SPELL_SLOTS[20] + SpellSlots::FULL_CASTER[class_level] || SpellSlots::FULL_CASTER[20] end def class_level @@ -56,7 +56,7 @@ def prepared_spells_amount # rubocop: enable Metrics/AbcSize def max_spell_level - ::Dnd2024::SubclassDecorator::SPELL_SLOTS[class_level].keys.max + SpellSlots::FULL_CASTER[class_level].keys.max end end end diff --git a/app/decorators_v2/dnd2024/classes/warlock_decorator.rb b/app/decorators_v2/dnd2024/classes/warlock_decorator.rb index fa7b32ba3..e661a7b2e 100644 --- a/app/decorators_v2/dnd2024/classes/warlock_decorator.rb +++ b/app/decorators_v2/dnd2024/classes/warlock_decorator.rb @@ -3,28 +3,7 @@ module Dnd2024 module Classes class WarlockDecorator < ApplicationDecoratorV2 - SPELL_SLOTS = { - 1 => { 1 => 1 }, - 2 => { 1 => 2 }, - 3 => { 1 => 0, 2 => 2 }, - 4 => { 1 => 0, 2 => 2 }, - 5 => { 1 => 0, 2 => 0, 3 => 2 }, - 6 => { 1 => 0, 2 => 0, 3 => 2 }, - 7 => { 1 => 0, 2 => 0, 3 => 0, 4 => 2 }, - 8 => { 1 => 0, 2 => 0, 3 => 0, 4 => 2 }, - 9 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 2 }, - 10 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 2 }, - 11 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0 }, - 12 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0 }, - 13 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0, 7 => 0 }, - 14 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0, 7 => 0 }, - 15 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0, 7 => 0, 8 => 0 }, - 16 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0, 7 => 0, 8 => 0 }, - 17 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 4, 6 => 0, 7 => 0, 8 => 0, 9 => 0 }, - 18 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 4, 6 => 0, 7 => 0, 8 => 0, 9 => 0 }, - 19 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 4, 6 => 0, 7 => 0, 8 => 0, 9 => 0 }, - 20 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 4, 6 => 0, 7 => 0, 8 => 0, 9 => 0 } - }.freeze + SPELL_SLOTS = SpellSlots::WARLOCK CLASS_SAVE_DC = %w[wis cha].freeze def call(result:) diff --git a/app/decorators_v2/dnd2024/classes/wizard_decorator.rb b/app/decorators_v2/dnd2024/classes/wizard_decorator.rb index 012033ad4..baba174a1 100644 --- a/app/decorators_v2/dnd2024/classes/wizard_decorator.rb +++ b/app/decorators_v2/dnd2024/classes/wizard_decorator.rb @@ -27,7 +27,7 @@ def spell_class_info end def spells_slots - ::Dnd2024::SubclassDecorator::SPELL_SLOTS[class_level] || ::Dnd2024::SubclassDecorator::SPELL_SLOTS[20] + SpellSlots::FULL_CASTER[class_level] || SpellSlots::FULL_CASTER[20] end def class_level @@ -51,7 +51,7 @@ def prepared_spells_amount end def max_spell_level - ::Dnd2024::SubclassDecorator::SPELL_SLOTS[class_level].keys.max + SpellSlots::FULL_CASTER[class_level].keys.max end end end diff --git a/app/decorators_v2/dnd2024/subclass_decorator.rb b/app/decorators_v2/dnd2024/subclass_decorator.rb index 88003c20b..cbe4e133e 100644 --- a/app/decorators_v2/dnd2024/subclass_decorator.rb +++ b/app/decorators_v2/dnd2024/subclass_decorator.rb @@ -2,28 +2,7 @@ module Dnd2024 class SubclassDecorator < ApplicationDecoratorV2 - SPELL_SLOTS = { - 1 => { 1 => 2 }, - 2 => { 1 => 3 }, - 3 => { 1 => 4, 2 => 2 }, - 4 => { 1 => 4, 2 => 3 }, - 5 => { 1 => 4, 2 => 3, 3 => 2 }, - 6 => { 1 => 4, 2 => 3, 3 => 3 }, - 7 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 }, - 8 => { 1 => 4, 2 => 3, 3 => 3, 4 => 2 }, - 9 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 1 }, - 10 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2 }, - 11 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1 }, - 12 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1 }, - 13 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1, 7 => 1 }, - 14 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1, 7 => 1 }, - 15 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1, 7 => 1, 8 => 1 }, - 16 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1, 7 => 1, 8 => 1 }, - 17 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1, 7 => 1, 8 => 1, 9 => 1 }, - 18 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 3, 6 => 1, 7 => 1, 8 => 1, 9 => 1 }, - 19 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 3, 6 => 2, 7 => 1, 8 => 1, 9 => 1 }, - 20 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 3, 6 => 2, 7 => 2, 8 => 1, 9 => 1 } - }.freeze + SPELL_SLOTS = SpellSlots::FULL_CASTER def call(result:) subclass_keys = result['subclasses'].values.compact diff --git a/app/decorators_v2/dnd2024/subclasses/arcane_trickster_decorator.rb b/app/decorators_v2/dnd2024/subclasses/arcane_trickster_decorator.rb index 669ce6c0e..78850ddcb 100644 --- a/app/decorators_v2/dnd2024/subclasses/arcane_trickster_decorator.rb +++ b/app/decorators_v2/dnd2024/subclasses/arcane_trickster_decorator.rb @@ -2,78 +2,10 @@ module Dnd2024 module Subclasses - class ArcaneTricksterDecorator < ApplicationDecoratorV2 - SPELL_SLOTS = { - 1 => { 1 => 0 }, - 2 => { 1 => 0 }, - 3 => { 1 => 2 }, - 4 => { 1 => 3 }, - 5 => { 1 => 3 }, - 6 => { 1 => 3 }, - 7 => { 1 => 4, 2 => 2 }, - 8 => { 1 => 4, 2 => 2 }, - 9 => { 1 => 4, 2 => 2 }, - 10 => { 1 => 4, 2 => 3 }, - 11 => { 1 => 4, 2 => 3 }, - 12 => { 1 => 4, 2 => 3 }, - 13 => { 1 => 4, 2 => 3, 3 => 2 }, - 14 => { 1 => 4, 2 => 3, 3 => 2 }, - 15 => { 1 => 4, 2 => 3, 3 => 2 }, - 16 => { 1 => 4, 2 => 3, 3 => 3 }, - 17 => { 1 => 4, 2 => 3, 3 => 3 }, - 18 => { 1 => 4, 2 => 3, 3 => 3 }, - 19 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 }, - 20 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 } - }.freeze - - def call(result:) - @result = result - @result['spell_classes']['rogue'] = spell_class_info - @result['spells_slots'] = SPELL_SLOTS[class_level] || SPELL_SLOTS[20] - @result - end - + class ArcaneTricksterDecorator < ThirdCasterDecorator private - def spell_class_info - { - save_dc: 8 + proficiency_bonus + modifiers['int'], - attack_bonus: proficiency_bonus + modifiers['int'], - cantrips_amount: cantrips_amount, - max_spell_level: max_spell_level, - prepared_spells_amount: prepared_spells_amount, - multiclass_spell_level: class_level / 3 - } - end - - def class_level - @class_level ||= classes['rogue'] - end - - def cantrips_amount - return 3 if class_level >= 10 - - 2 - end - - def prepared_spells_amount # rubocop: disable Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/CyclomaticComplexity - return 13 if class_level >= 20 - return 12 if class_level >= 19 - return 11 if class_level >= 16 - return 10 if class_level >= 14 - return 9 if class_level >= 13 - return 8 if class_level >= 11 - return 7 if class_level >= 10 - return 6 if class_level >= 8 - return 5 if class_level >= 7 - return 4 if class_level >= 4 - - 3 - end - - def max_spell_level - SPELL_SLOTS[class_level].keys.max - end + def spellcasting_class = 'rogue' end end end diff --git a/app/decorators_v2/dnd2024/subclasses/eldritch_knight_decorator.rb b/app/decorators_v2/dnd2024/subclasses/eldritch_knight_decorator.rb index f1e35c5dc..e2814ff7c 100644 --- a/app/decorators_v2/dnd2024/subclasses/eldritch_knight_decorator.rb +++ b/app/decorators_v2/dnd2024/subclasses/eldritch_knight_decorator.rb @@ -2,78 +2,10 @@ module Dnd2024 module Subclasses - class EldritchKnightDecorator < ApplicationDecoratorV2 - SPELL_SLOTS = { - 1 => { 1 => 0 }, - 2 => { 1 => 0 }, - 3 => { 1 => 2 }, - 4 => { 1 => 3 }, - 5 => { 1 => 3 }, - 6 => { 1 => 3 }, - 7 => { 1 => 4, 2 => 2 }, - 8 => { 1 => 4, 2 => 2 }, - 9 => { 1 => 4, 2 => 2 }, - 10 => { 1 => 4, 2 => 3 }, - 11 => { 1 => 4, 2 => 3 }, - 12 => { 1 => 4, 2 => 3 }, - 13 => { 1 => 4, 2 => 3, 3 => 2 }, - 14 => { 1 => 4, 2 => 3, 3 => 2 }, - 15 => { 1 => 4, 2 => 3, 3 => 2 }, - 16 => { 1 => 4, 2 => 3, 3 => 3 }, - 17 => { 1 => 4, 2 => 3, 3 => 3 }, - 18 => { 1 => 4, 2 => 3, 3 => 3 }, - 19 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 }, - 20 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 } - }.freeze - - def call(result:) - @result = result - @result['spell_classes']['fighter'] = spell_class_info - @result['spells_slots'] = SPELL_SLOTS[class_level] || SPELL_SLOTS[20] - @result - end - + class EldritchKnightDecorator < ThirdCasterDecorator private - def spell_class_info - { - save_dc: 8 + proficiency_bonus + modifiers['int'], - attack_bonus: proficiency_bonus + modifiers['int'], - cantrips_amount: cantrips_amount, - max_spell_level: max_spell_level, - prepared_spells_amount: prepared_spells_amount, - multiclass_spell_level: class_level / 3 - } - end - - def class_level - @class_level ||= classes['fighter'] - end - - def cantrips_amount - return 3 if class_level >= 10 - - 2 - end - - def prepared_spells_amount # rubocop: disable Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/CyclomaticComplexity - return 13 if class_level >= 20 - return 12 if class_level >= 19 - return 11 if class_level >= 16 - return 10 if class_level >= 14 - return 9 if class_level >= 13 - return 8 if class_level >= 11 - return 7 if class_level >= 10 - return 6 if class_level >= 8 - return 5 if class_level >= 7 - return 4 if class_level >= 4 - - 3 - end - - def max_spell_level - SPELL_SLOTS[class_level].keys.max - end + def spellcasting_class = 'fighter' end end end diff --git a/app/decorators_v2/dnd2024/subclasses/third_caster_decorator.rb b/app/decorators_v2/dnd2024/subclasses/third_caster_decorator.rb new file mode 100644 index 000000000..68b3aa7c0 --- /dev/null +++ b/app/decorators_v2/dnd2024/subclasses/third_caster_decorator.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Dnd2024 + module Subclasses + # Third-caster progression shared by Arcane Trickster (rogue) and Eldritch + # Knight (fighter): same slots, same Intelligence-based DCs, same prepared + # spell counts. Subclasses only name the class the spellcasting hangs off. + class ThirdCasterDecorator < ApplicationDecoratorV2 + SPELL_SLOTS = SpellSlots::THIRD_CASTER + + def call(result:) + @result = result + @result['spell_classes'][spellcasting_class] = spell_class_info + @result['spells_slots'] = SPELL_SLOTS[class_level] || SPELL_SLOTS[20] + @result + end + + private + + def spellcasting_class = raise(NotImplementedError) + + def spell_class_info + { + save_dc: 8 + proficiency_bonus + modifiers['int'], + attack_bonus: proficiency_bonus + modifiers['int'], + cantrips_amount: cantrips_amount, + max_spell_level: max_spell_level, + prepared_spells_amount: prepared_spells_amount, + multiclass_spell_level: class_level / 3 + } + end + + def class_level + @class_level ||= classes[spellcasting_class] + end + + def cantrips_amount + return 3 if class_level >= 10 + + 2 + end + + def prepared_spells_amount # rubocop: disable Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/CyclomaticComplexity + return 13 if class_level >= 20 + return 12 if class_level >= 19 + return 11 if class_level >= 16 + return 10 if class_level >= 14 + return 9 if class_level >= 13 + return 8 if class_level >= 11 + return 7 if class_level >= 10 + return 6 if class_level >= 8 + return 5 if class_level >= 7 + return 4 if class_level >= 4 + + 3 + end + + def max_spell_level + SPELL_SLOTS[class_level].keys.max + end + end + end +end diff --git a/app/javascript/applications/CharKeeperApp/components/atoms/Input.jsx b/app/javascript/applications/CharKeeperApp/components/atoms/Input.jsx index c12da84e3..8ad71f08a 100644 --- a/app/javascript/applications/CharKeeperApp/components/atoms/Input.jsx +++ b/app/javascript/applications/CharKeeperApp/components/atoms/Input.jsx @@ -1,4 +1,4 @@ -import { Switch, Match, splitProps } from 'solid-js'; +import { splitProps } from 'solid-js'; import { Label } from './Label'; @@ -12,56 +12,30 @@ export const Input = (props) => { props.onKeyDown(event); } + const type = () => { + if (props.numeric) return 'number'; + if (props.password) return 'password'; + + return 'text'; + } + return (
); } diff --git a/app/javascript/applications/HomebrewsApp/components/atoms/Input.jsx b/app/javascript/applications/HomebrewsApp/components/atoms/Input.jsx index 9b446bedb..0281d35ba 100644 --- a/app/javascript/applications/HomebrewsApp/components/atoms/Input.jsx +++ b/app/javascript/applications/HomebrewsApp/components/atoms/Input.jsx @@ -1,4 +1,4 @@ -import { Switch, Match, splitProps } from 'solid-js'; +import { splitProps } from 'solid-js'; import { Label } from './Label'; @@ -17,31 +17,16 @@ export const Input = (props) => { return (
); } diff --git a/app/lib/spell_slots.rb b/app/lib/spell_slots.rb new file mode 100644 index 000000000..5583df5c7 --- /dev/null +++ b/app/lib/spell_slots.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +# Spell slot progressions, keyed by class level and then by spell level. The +# dnd5 and dnd2024 rule sets publish the same tables, so class and subclass +# decorators of both providers read them from here. +module SpellSlots + FULL_CASTER = { + 1 => { 1 => 2 }, + 2 => { 1 => 3 }, + 3 => { 1 => 4, 2 => 2 }, + 4 => { 1 => 4, 2 => 3 }, + 5 => { 1 => 4, 2 => 3, 3 => 2 }, + 6 => { 1 => 4, 2 => 3, 3 => 3 }, + 7 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 }, + 8 => { 1 => 4, 2 => 3, 3 => 3, 4 => 2 }, + 9 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 1 }, + 10 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2 }, + 11 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1 }, + 12 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1 }, + 13 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1, 7 => 1 }, + 14 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1, 7 => 1 }, + 15 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1, 7 => 1, 8 => 1 }, + 16 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1, 7 => 1, 8 => 1 }, + 17 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2, 6 => 1, 7 => 1, 8 => 1, 9 => 1 }, + 18 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 3, 6 => 1, 7 => 1, 8 => 1, 9 => 1 }, + 19 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 3, 6 => 2, 7 => 1, 8 => 1, 9 => 1 }, + 20 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 3, 6 => 2, 7 => 2, 8 => 1, 9 => 1 } + }.freeze + + HALF_CASTER = { + 1 => { 1 => 2 }, + 2 => { 1 => 2 }, + 3 => { 1 => 3 }, + 4 => { 1 => 3 }, + 5 => { 1 => 4, 2 => 2 }, + 6 => { 1 => 4, 2 => 2 }, + 7 => { 1 => 4, 2 => 3 }, + 8 => { 1 => 4, 2 => 3 }, + 9 => { 1 => 4, 2 => 3, 3 => 2 }, + 10 => { 1 => 4, 2 => 3, 3 => 2 }, + 11 => { 1 => 4, 2 => 3, 3 => 3 }, + 12 => { 1 => 4, 2 => 3, 3 => 3 }, + 13 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 }, + 14 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 }, + 15 => { 1 => 4, 2 => 3, 3 => 3, 4 => 2 }, + 16 => { 1 => 4, 2 => 3, 3 => 3, 4 => 2 }, + 17 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 1 }, + 18 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 1 }, + 19 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2 }, + 20 => { 1 => 4, 2 => 3, 3 => 3, 4 => 3, 5 => 2 } + }.freeze + + THIRD_CASTER = { + 1 => { 1 => 0 }, + 2 => { 1 => 0 }, + 3 => { 1 => 2 }, + 4 => { 1 => 3 }, + 5 => { 1 => 3 }, + 6 => { 1 => 3 }, + 7 => { 1 => 4, 2 => 2 }, + 8 => { 1 => 4, 2 => 2 }, + 9 => { 1 => 4, 2 => 2 }, + 10 => { 1 => 4, 2 => 3 }, + 11 => { 1 => 4, 2 => 3 }, + 12 => { 1 => 4, 2 => 3 }, + 13 => { 1 => 4, 2 => 3, 3 => 2 }, + 14 => { 1 => 4, 2 => 3, 3 => 2 }, + 15 => { 1 => 4, 2 => 3, 3 => 2 }, + 16 => { 1 => 4, 2 => 3, 3 => 3 }, + 17 => { 1 => 4, 2 => 3, 3 => 3 }, + 18 => { 1 => 4, 2 => 3, 3 => 3 }, + 19 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 }, + 20 => { 1 => 4, 2 => 3, 3 => 3, 4 => 1 } + }.freeze + + # Pact magic: the zeroes are slot levels the warlock no longer has, kept so + # the sheet can render an empty row instead of dropping the level. + WARLOCK = { + 1 => { 1 => 1 }, + 2 => { 1 => 2 }, + 3 => { 1 => 0, 2 => 2 }, + 4 => { 1 => 0, 2 => 2 }, + 5 => { 1 => 0, 2 => 0, 3 => 2 }, + 6 => { 1 => 0, 2 => 0, 3 => 2 }, + 7 => { 1 => 0, 2 => 0, 3 => 0, 4 => 2 }, + 8 => { 1 => 0, 2 => 0, 3 => 0, 4 => 2 }, + 9 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 2 }, + 10 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 2 }, + 11 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0 }, + 12 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0 }, + 13 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0, 7 => 0 }, + 14 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0, 7 => 0 }, + 15 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0, 7 => 0, 8 => 0 }, + 16 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 3, 6 => 0, 7 => 0, 8 => 0 }, + 17 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 4, 6 => 0, 7 => 0, 8 => 0, 9 => 0 }, + 18 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 4, 6 => 0, 7 => 0, 8 => 0, 9 => 0 }, + 19 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 4, 6 => 0, 7 => 0, 8 => 0, 9 => 0 }, + 20 => { 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 4, 6 => 0, 7 => 0, 8 => 0, 9 => 0 } + }.freeze +end diff --git a/app/serializers/campaigns/item_serializer.rb b/app/serializers/campaigns/item_serializer.rb index 34eb0ddb5..e8b86c0a7 100644 --- a/app/serializers/campaigns/item_serializer.rb +++ b/app/serializers/campaigns/item_serializer.rb @@ -2,41 +2,12 @@ module Campaigns class ItemSerializer < ApplicationSerializer + include ItemSerializing + ATTRIBUTES = %i[ id notes name kind data item_id has_description states info bonuses modifiers item_modifiers custom ].freeze attributes(*ATTRIBUTES) - - delegate :kind, :info, to: :item - delegate :item, to: :object - - def bonuses - resp = Panko::ArraySerializer.new( - object.item.bonuses, - each_serializer: Characters::BonusSerializer - ) - JSON.parse(resp.to_json) - end - - def name - object.name || translate(item.name) - end - - def item_modifiers # rubocop: disable Rails/Delegate - item.modifiers - end - - def has_description # rubocop: disable Naming/PredicateMethod, Naming/PredicatePrefix - translate(item.description).present? - end - - def data - item.data.attributes - end - - def custom # rubocop: disable Naming/PredicateMethod - object.name.present? - end end end diff --git a/app/serializers/characters/item_serializer.rb b/app/serializers/characters/item_serializer.rb index 88dbc718c..1a3f4dfc0 100644 --- a/app/serializers/characters/item_serializer.rb +++ b/app/serializers/characters/item_serializer.rb @@ -2,6 +2,8 @@ module Characters class ItemSerializer < ApplicationSerializer + include ItemSerializing + ATTRIBUTES = %i[ id notes name kind data state item_id has_description states info bonuses modifiers item_modifiers custom charges charges_max @@ -9,37 +11,6 @@ class ItemSerializer < ApplicationSerializer attributes(*ATTRIBUTES) - delegate :kind, :info, to: :item - delegate :item, to: :object - - def bonuses - resp = Panko::ArraySerializer.new( - object.item.bonuses, - each_serializer: Characters::BonusSerializer - ) - JSON.parse(resp.to_json) - end - - def name - object.name || translate(item.name) - end - - def item_modifiers # rubocop: disable Rails/Delegate - item.modifiers - end - - def has_description # rubocop: disable Naming/PredicateMethod, Naming/PredicatePrefix - translate(item.description).present? - end - - def data - item.data.attributes - end - - def custom # rubocop: disable Naming/PredicateMethod - object.name.present? - end - def charges_max item.charges end diff --git a/app/serializers/concerns/item_serializing.rb b/app/serializers/concerns/item_serializing.rb new file mode 100644 index 000000000..332904791 --- /dev/null +++ b/app/serializers/concerns/item_serializing.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Item payload shared by campaign and character items: both wrap an owned copy of +# a catalogue item, and read everything but the owner's overrides off that item. +module ItemSerializing + extend ActiveSupport::Concern + + included do + delegate :kind, :info, to: :item + delegate :item, to: :object + end + + def bonuses + resp = Panko::ArraySerializer.new( + object.item.bonuses, + each_serializer: Characters::BonusSerializer + ) + JSON.parse(resp.to_json) + end + + def name + object.name || translate(item.name) + end + + def item_modifiers # rubocop: disable Rails/Delegate + item.modifiers + end + + def has_description # rubocop: disable Naming/PredicateMethod, Naming/PredicatePrefix + translate(item.description).present? + end + + def data + item.data.attributes + end + + def custom # rubocop: disable Naming/PredicateMethod + object.name.present? + end +end diff --git a/app/services/characters_context/dnd2024/refresh_feats.rb b/app/services/characters_context/dnd2024/refresh_feats.rb index c9b9b2bd4..df3513f03 100644 --- a/app/services/characters_context/dnd2024/refresh_feats.rb +++ b/app/services/characters_context/dnd2024/refresh_feats.rb @@ -3,59 +3,16 @@ module CharactersContext module Dnd2024 class RefreshFeats < CharactersContext::RefreshFeats + include CharactersContext::FeatFiltering + REQUIRED_ATTRIBUTES = %i[id slug conditions origin origin_value limit_refresh exclude tokens].freeze private - def remove_redundant_feats(...); end - def exclude_origins_from_remove ::Dnd2024::Feat::SELECTABLE_ORIGINS end - def filter_available_feats(character) - selected_feats = find_selected_feats(character) - subclasses_levels = find_subclasses_levels(character) - - feats(character).select(*REQUIRED_ATTRIBUTES).filter_map do |item| - next item if item.conditions.blank? - - filter_feat(item, character, subclasses_levels, selected_feats) - end - end - - def filter_feat(item, character, subclasses_levels, selected_feats) - conditions = item.conditions - return unless match_by_level?(conditions['level'], item, character, subclasses_levels) - return unless match_by_selected_feats?(conditions['selected_feature'], selected_feats) - - item - end - - def match_by_level?(condition, item, character, subclasses_levels) - return true unless condition - return false if item.origin == 'subclass' && subclasses_levels[item.origin_value] < condition - return false if item.origin == 'class' && character.data.classes[item.origin_value] < condition - return false if item.origin == 'species' && character.data.level < condition - - true - end - - def match_by_selected_feats?(condition, selected_feats) - return true unless condition - return false if ([condition] - selected_feats).any? - - true - end - - def find_selected_feats(character) - character.data.selected_features.values.flatten - end - - def find_subclasses_levels(character) - character.data.subclasses.to_h { |key, value| [value, character.data.classes[key]] } - end - def feats(character) data = character.data ::Dnd2024::Feat.where( diff --git a/app/services/characters_context/dnd5/refresh_feats.rb b/app/services/characters_context/dnd5/refresh_feats.rb index 01e6e123d..fb64009ff 100644 --- a/app/services/characters_context/dnd5/refresh_feats.rb +++ b/app/services/characters_context/dnd5/refresh_feats.rb @@ -3,35 +3,19 @@ module CharactersContext module Dnd5 class RefreshFeats < CharactersContext::RefreshFeats + include CharactersContext::FeatFiltering + REQUIRED_ATTRIBUTES = %i[id slug conditions origin origin_value limit_refresh exclude tokens].freeze private - def remove_redundant_feats(...); end + # dnd5 feats gate on an array of `selected_feats` and have no species origin. + def selected_feats_condition_key = 'selected_feats' def exclude_origins_from_remove ::Dnd5::Feat::SELECTABLE_ORIGINS end - def filter_available_feats(character) - selected_feats = find_selected_feats(character) - subclasses_levels = find_subclasses_levels(character) - - feats(character).select(*REQUIRED_ATTRIBUTES).filter_map do |item| - next item if item.conditions.blank? - - filter_feat(item, character, subclasses_levels, selected_feats) - end - end - - def filter_feat(item, character, subclasses_levels, selected_feats) - conditions = item.conditions - return unless match_by_level?(conditions['level'], item, character, subclasses_levels) - return unless match_by_selected_feats?(conditions['selected_feats'], selected_feats) - - item - end - def match_by_level?(condition, item, character, subclasses_levels) return true unless condition return false if item.origin == 'subclass' && subclasses_levels[item.origin_value] < condition @@ -51,10 +35,6 @@ def find_selected_feats(character) character.data.selected_feats.values.flatten end - def find_subclasses_levels(character) - character.data.subclasses.to_h { |key, value| [value, character.data.classes[key]] } - end - def feats(character) data = character.data ::Dnd5::Feat.where( diff --git a/app/services/characters_context/feat_filtering.rb b/app/services/characters_context/feat_filtering.rb new file mode 100644 index 000000000..d87057b4d --- /dev/null +++ b/app/services/characters_context/feat_filtering.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module CharactersContext + # Level/selection gating of the feat rows a character qualifies for, shared by + # every provider's RefreshFeats. Defaults follow the D&D 2024 rules (feats + # gated by `selected_feature` and species level); dnd5 overrides the three + # predicates that read its older feat shape. Including classes must supply + # `feats` (the content scope) and a `REQUIRED_ATTRIBUTES` list. + module FeatFiltering + private + + def feats(_character) = raise(NotImplementedError) + def selected_feats_condition_key = 'selected_feature' + def remove_redundant_feats(...); end + + def filter_available_feats(character) + selected_feats = find_selected_feats(character) + subclasses_levels = find_subclasses_levels(character) + + feats(character).select(*self.class::REQUIRED_ATTRIBUTES).filter_map do |item| + next item if item.conditions.blank? + + filter_feat(item, character, subclasses_levels, selected_feats) + end + end + + def filter_feat(item, character, subclasses_levels, selected_feats) + conditions = item.conditions + return unless match_by_level?(conditions['level'], item, character, subclasses_levels) + return unless match_by_selected_feats?(conditions[selected_feats_condition_key], selected_feats) + + item + end + + def match_by_level?(condition, item, character, subclasses_levels) + return true unless condition + return false if item.origin == 'subclass' && subclasses_levels[item.origin_value] < condition + return false if item.origin == 'class' && character.data.classes[item.origin_value] < condition + return false if item.origin == 'species' && character.data.level < condition + + true + end + + def match_by_selected_feats?(condition, selected_feats) + return true unless condition + return false if ([condition] - selected_feats).any? + + true + end + + def find_selected_feats(character) + character.data.selected_features.values.flatten + end + + def find_subclasses_levels(character) + character.data.subclasses.to_h { |key, value| [value, character.data.classes[key]] } + end + end +end diff --git a/app/services/characters_context/tlc/refresh_feats.rb b/app/services/characters_context/tlc/refresh_feats.rb index c587e10ef..20e844700 100644 --- a/app/services/characters_context/tlc/refresh_feats.rb +++ b/app/services/characters_context/tlc/refresh_feats.rb @@ -3,6 +3,8 @@ module CharactersContext module Tlc class RefreshFeats < CharactersContext::RefreshFeats + include CharactersContext::FeatFiltering + # `type` is REQUIRED here: feats() unions on the base Feat class (tlc_content), # so a select() that omits `type` cannot resolve STI and instantiates rows as # base `Feat` -- whose `origin`/`limit_refresh` enums are undeclared, raising @@ -12,55 +14,10 @@ class RefreshFeats < CharactersContext::RefreshFeats private - def remove_redundant_feats(...); end - def exclude_origins_from_remove ::Tlc::Feat::SELECTABLE_ORIGINS end - def filter_available_feats(character) - selected_feats = find_selected_feats(character) - subclasses_levels = find_subclasses_levels(character) - - feats(character).select(*REQUIRED_ATTRIBUTES).filter_map do |item| - next item if item.conditions.blank? - - filter_feat(item, character, subclasses_levels, selected_feats) - end - end - - def filter_feat(item, character, subclasses_levels, selected_feats) - conditions = item.conditions - return unless match_by_level?(conditions['level'], item, character, subclasses_levels) - return unless match_by_selected_feats?(conditions['selected_feature'], selected_feats) - - item - end - - def match_by_level?(condition, item, character, subclasses_levels) - return true unless condition - return false if item.origin == 'subclass' && subclasses_levels[item.origin_value] < condition - return false if item.origin == 'class' && character.data.classes[item.origin_value] < condition - return false if item.origin == 'species' && character.data.level < condition - - true - end - - def match_by_selected_feats?(condition, selected_feats) - return true unless condition - return false if ([condition] - selected_feats).any? - - true - end - - def find_selected_feats(character) - character.data.selected_features.values.flatten - end - - def find_subclasses_levels(character) - character.data.subclasses.to_h { |key, value| [value, character.data.classes[key]] } - end - def feats(character) data = character.data # Shared-content union -- the same scope the spells path uses at