From c19259c84a74200c0278d4fbeb21b0f5d90c0d7a Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 3 Dec 2025 12:58:32 -0800 Subject: [PATCH] add artifact models with skill validations --- app/models/artifact.rb | 36 ++++++ app/models/artifact_skill.rb | 74 ++++++++++++ app/models/collection_artifact.rb | 60 ++++++++++ .../concerns/artifact_skill_validations.rb | 108 ++++++++++++++++++ app/models/grid_artifact.rb | 95 +++++++++++++++ app/models/grid_character.rb | 4 + app/models/user.rb | 1 + 7 files changed, 378 insertions(+) create mode 100644 app/models/artifact.rb create mode 100644 app/models/artifact_skill.rb create mode 100644 app/models/collection_artifact.rb create mode 100644 app/models/concerns/artifact_skill_validations.rb create mode 100644 app/models/grid_artifact.rb diff --git a/app/models/artifact.rb b/app/models/artifact.rb new file mode 100644 index 0000000..574529b --- /dev/null +++ b/app/models/artifact.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class Artifact < ApplicationRecord + # Enums - using GranblueEnums::PROFICIENCY values (excluding None: 0) + # Sabre: 1, Dagger: 2, Axe: 3, Spear: 4, Bow: 5, Staff: 6, Melee: 7, Harp: 8, Gun: 9, Katana: 10 + enum :proficiency, { + sabre: 1, + dagger: 2, + axe: 3, + spear: 4, + bow: 5, + staff: 6, + melee: 7, + harp: 8, + gun: 9, + katana: 10 + } + + enum :rarity, { standard: 0, quirk: 1 } + + # Associations + has_many :collection_artifacts, dependent: :restrict_with_error + has_many :grid_artifacts, dependent: :restrict_with_error + + # Validations + validates :granblue_id, presence: true, uniqueness: true + validates :name_en, presence: true + validates :proficiency, presence: true, if: :standard? + validates :proficiency, absence: true, if: :quirk? + validates :rarity, presence: true + + # Scopes + scope :standard_artifacts, -> { where(rarity: :standard) } + scope :quirk_artifacts, -> { where(rarity: :quirk) } + scope :by_proficiency, ->(prof) { where(proficiency: prof) } +end diff --git a/app/models/artifact_skill.rb b/app/models/artifact_skill.rb new file mode 100644 index 0000000..8d6c6f8 --- /dev/null +++ b/app/models/artifact_skill.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +class ArtifactSkill < ApplicationRecord + # Enums + enum :skill_group, { group_i: 1, group_ii: 2, group_iii: 3 } + enum :polarity, { positive: 'positive', negative: 'negative' } + + # Validations + validates :skill_group, presence: true + validates :modifier, presence: true, uniqueness: { scope: :skill_group } + validates :name_en, presence: true + validates :name_jp, presence: true + validates :base_values, presence: true + validates :polarity, presence: true + + # Scopes + scope :for_slot, ->(slot) { + case slot + when 1, 2 then group_i + when 3 then group_ii + when 4 then group_iii + end + } + + # Class methods for caching skill lookups + class << self + def cached_skills + @cached_skills ||= all.index_by { |s| [s.skill_group, s.modifier] } + end + + def find_skill(group, modifier) + # Convert group number to enum key + group_key = case group + when 1 then 'group_i' + when 2 then 'group_ii' + when 3 then 'group_iii' + else group.to_s + end + cached_skills[[group_key, modifier]] + end + + def clear_cache! + @cached_skills = nil + end + end + + # Calculate the current value of a skill given base strength and skill level + # @param base_strength [Numeric] The base strength value of the skill + # @param skill_level [Integer] The current skill level (1-5) + # @return [Numeric, nil] The calculated value + def calculate_value(base_strength, skill_level) + return base_strength if growth.nil? + + base_strength + (growth * (skill_level - 1)) + end + + # Format a value with the appropriate suffix + # @param value [Numeric] The value to format + # @param locale [Symbol] :en or :jp + # @return [String] The formatted value with suffix + def format_value(value, locale = :en) + suffix = locale == :jp ? suffix_jp : suffix_en + "#{value}#{suffix}" + end + + # Check if a strength value is valid for this skill + # @param strength [Numeric] The strength value to validate + # @return [Boolean] + def valid_strength?(strength) + return true if base_values.include?(nil) # Unknown values are always valid + + base_values.include?(strength) + end +end diff --git a/app/models/collection_artifact.rb b/app/models/collection_artifact.rb new file mode 100644 index 0000000..1c7b75b --- /dev/null +++ b/app/models/collection_artifact.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class CollectionArtifact < ApplicationRecord + include ArtifactSkillValidations + + # Associations + belongs_to :user + belongs_to :artifact + + # Enums - using GranblueEnums::ELEMENTS values (excluding Null) + # Wind: 1, Fire: 2, Water: 3, Earth: 4, Dark: 5, Light: 6 + enum :element, { + wind: 1, + fire: 2, + water: 3, + earth: 4, + dark: 5, + light: 6 + } + + # Proficiency enum - only used for quirk artifacts (game assigns random proficiency) + enum :proficiency, { + sabre: 1, + dagger: 2, + axe: 3, + spear: 4, + bow: 5, + staff: 6, + melee: 7, + harp: 8, + gun: 9, + katana: 10 + } + + # Validations + validates :element, presence: true + validates :level, presence: true, inclusion: { in: 1..5 } + validates :nickname, length: { maximum: 50 }, allow_blank: true + validates :proficiency, presence: true, if: :quirk_artifact? + validates :proficiency, absence: true, unless: :quirk_artifact? + + # Scopes + scope :by_element, ->(el) { where(element: el) } + scope :by_artifact, ->(artifact_id) { where(artifact_id: artifact_id) } + scope :by_proficiency, ->(prof) { where(proficiency: prof) } + scope :by_rarity, ->(rar) { joins(:artifact).where(artifacts: { rarity: rar }) } + scope :standard_only, -> { joins(:artifact).where(artifacts: { rarity: :standard }) } + scope :quirk_only, -> { joins(:artifact).where(artifacts: { rarity: :quirk }) } + + # Returns the effective proficiency - from instance for quirk, from artifact for standard + def effective_proficiency + quirk_artifact? ? proficiency : artifact&.proficiency + end + + private + + def quirk_artifact? + artifact&.quirk? + end +end diff --git a/app/models/concerns/artifact_skill_validations.rb b/app/models/concerns/artifact_skill_validations.rb new file mode 100644 index 0000000..a6e12c5 --- /dev/null +++ b/app/models/concerns/artifact_skill_validations.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module ArtifactSkillValidations + extend ActiveSupport::Concern + + included do + validate :validate_skill1_group_i + validate :validate_skill2_group_i + validate :validate_skill3_group_ii + validate :validate_skill4_group_iii + validate :validate_duplicate_skills + validate :validate_skill_levels_sum, unless: :quirk_artifact? + validate :validate_quirk_artifact_constraints, if: :quirk_artifact? + end + + private + + def quirk_artifact? + artifact&.quirk? + end + + def validate_skill_in_group(skill_data, group_number, slot_name) + return if skill_data.blank? || skill_data == {} + return if quirk_artifact? + + modifier = skill_data['modifier'] + strength = skill_data['strength'] + skill_level = skill_data['level'] + + unless modifier && strength && skill_level + errors.add(slot_name, 'must have modifier, strength, and level') + return + end + + skill_def = ArtifactSkill.find_skill(group_number, modifier) + unless skill_def + errors.add(slot_name, "has invalid modifier #{modifier}") + return + end + + unless (1..5).cover?(skill_level) + errors.add(slot_name, 'level must be between 1 and 5') + return + end + + # Validate strength is a valid base value for this skill + unless skill_def.valid_strength?(strength) + errors.add(slot_name, "has invalid base strength #{strength}") + end + end + + def validate_skill1_group_i + validate_skill_in_group(skill1, 1, :skill1) + end + + def validate_skill2_group_i + validate_skill_in_group(skill2, 1, :skill2) + end + + def validate_skill3_group_ii + validate_skill_in_group(skill3, 2, :skill3) + end + + def validate_skill4_group_iii + validate_skill_in_group(skill4, 3, :skill4) + end + + def validate_duplicate_skills + return if quirk_artifact? + + # Skills 1 and 2 are both from Group I and cannot have the same modifier + return if skill1.blank? || skill1 == {} || skill2.blank? || skill2 == {} + + if skill1['modifier'] == skill2['modifier'] + errors.add(:base, 'Skill 1 and Skill 2 cannot have the same modifier') + end + end + + def validate_skill_levels_sum + # For standard artifacts, skill levels must sum to (artifact_level + 3) + # At level 1: all skills level 1, sum = 4 + # At level 5: skills sum = 8 (distributed among 4 skills) + return if level.nil? + + skills = [skill1, skill2, skill3, skill4] + + # Skip validation if any skill is empty (incomplete artifact) + return if skills.any? { |s| s.blank? || s == {} } + + total = skills.sum { |s| s['level'].to_i } + expected = level + 3 + + return if total == expected + + errors.add(:base, "Skill levels must sum to #{expected} for artifact level #{level}, got #{total}") + end + + def validate_quirk_artifact_constraints + errors.add(:level, 'must be 1 for quirk artifacts') unless level == 1 + + # Quirk artifacts don't store skills + [skill1, skill2, skill3, skill4].each_with_index do |skill, idx| + next if skill.blank? || skill == {} + + errors.add(:"skill#{idx + 1}", 'must be empty for quirk artifacts') + end + end +end diff --git a/app/models/grid_artifact.rb b/app/models/grid_artifact.rb new file mode 100644 index 0000000..77a8db7 --- /dev/null +++ b/app/models/grid_artifact.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +class GridArtifact < ApplicationRecord + include ArtifactSkillValidations + + # Associations + belongs_to :grid_character + belongs_to :artifact + + has_one :party, through: :grid_character + has_one :character, through: :grid_character + + # Enums - using GranblueEnums::ELEMENTS values (excluding Null) + # Wind: 1, Fire: 2, Water: 3, Earth: 4, Dark: 5, Light: 6 + enum :element, { + wind: 1, + fire: 2, + water: 3, + earth: 4, + dark: 5, + light: 6 + } + + # Proficiency enum - only used for quirk artifacts (game assigns random proficiency) + enum :proficiency, { + sabre: 1, + dagger: 2, + axe: 3, + spear: 4, + bow: 5, + staff: 6, + melee: 7, + harp: 8, + gun: 9, + katana: 10 + } + + # Validations + validates :element, presence: true + validates :level, presence: true, inclusion: { in: 1..5 } + validates :proficiency, presence: true, if: :quirk_artifact? + validates :proficiency, absence: true, unless: :quirk_artifact? + + validate :validate_character_compatibility + + # Amoeba configuration for party duplication + amoeba do + enable + end + + # Returns the effective proficiency - from instance for quirk, from artifact for standard + def effective_proficiency + quirk_artifact? ? proficiency : artifact&.proficiency + end + + private + + def quirk_artifact? + artifact&.quirk? + end + + ## + # Validates that the artifact's element and proficiency match the character's requirements. + # + # - Element must match the character's element (unless character has variable element like Lyria) + # - Artifact's proficiency must match one of the character's proficiencies + # + # @return [void] + def validate_character_compatibility + return unless grid_character&.character && artifact + + char = grid_character.character + + # Check element compatibility + # Characters with element=0 (Null) can equip any element artifact (e.g., Lyria) + if char.element.present? && char.element != 0 + char_element = GranblueEnums::ELEMENTS.key(char.element)&.to_s&.downcase + unless char_element == element + errors.add(:element, "must match character's element (#{char_element})") + end + end + + # Check proficiency compatibility + # Use effective_proficiency to get the right value for both standard and quirk artifacts + eff_prof = effective_proficiency + return unless eff_prof # Skip if no proficiency available + + prof_value = Artifact.proficiencies[eff_prof] + char_proficiencies = [char.proficiency1, char.proficiency2].compact + + unless char_proficiencies.include?(prof_value) + errors.add(:artifact, "proficiency must match one of the character's proficiencies") + end + end +end diff --git a/app/models/grid_character.rb b/app/models/grid_character.rb index 58499ec..0a54b17 100644 --- a/app/models/grid_character.rb +++ b/app/models/grid_character.rb @@ -24,6 +24,8 @@ class GridCharacter < ApplicationRecord counter_cache: :characters_count, inverse_of: :characters + has_one :grid_artifact, dependent: :destroy + # Validations validates_presence_of :party @@ -42,6 +44,8 @@ class GridCharacter < ApplicationRecord ##### Amoeba configuration amoeba do + enable + include_association :grid_artifact set ring1: { modifier: nil, strength: nil } set ring2: { modifier: nil, strength: nil } set ring3: { modifier: nil, strength: nil } diff --git a/app/models/user.rb b/app/models/user.rb index d0714e1..2b8eea3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -10,6 +10,7 @@ class User < ApplicationRecord has_many :collection_weapons, dependent: :destroy has_many :collection_summons, dependent: :destroy has_many :collection_job_accessories, dependent: :destroy + has_many :collection_artifacts, dependent: :destroy # Note: The crew association will be added when crews feature is implemented # belongs_to :crew, optional: true