442 lines
15 KiB
Ruby
442 lines
15 KiB
Ruby
# frozen_string_literal: true
|
||
|
||
##
|
||
# This file defines the GridCharacter model which represents a character's grid configuration within a party.
|
||
# The GridCharacter model handles validations related to awakenings, rings, mastery values, and transcendence.
|
||
# It includes virtual attributes for processing new rings and awakening data, and utilizes the amoeba gem
|
||
# for duplicating records with specific attribute resets.
|
||
#
|
||
# @note This model belongs to a Character, an optional Awakening, and a Party. It maintains associations for
|
||
# these relationships and includes counter caches for performance optimization.
|
||
#
|
||
# @!attribute [r] character
|
||
# @return [Character] the associated character record.
|
||
# @!attribute [r] awakening
|
||
# @return [Awakening, nil] the associated awakening record (optional).
|
||
# @!attribute [r] party
|
||
# @return [Party] the associated party record.
|
||
#
|
||
class GridCharacter < ApplicationRecord
|
||
# Associations
|
||
belongs_to :character, foreign_key: :character_id, primary_key: :id
|
||
belongs_to :awakening, optional: true
|
||
belongs_to :party,
|
||
counter_cache: :characters_count,
|
||
inverse_of: :characters
|
||
belongs_to :collection_character, optional: true
|
||
|
||
has_one :grid_artifact, dependent: :destroy
|
||
|
||
# Validations
|
||
validates_presence_of :party
|
||
|
||
# Validate that uncap_level is present and numeric, transcendence_step is optional but must be numeric if present.
|
||
validates :uncap_level, presence: true, numericality: { only_integer: true }
|
||
validates :transcendence_step, numericality: { only_integer: true }, allow_nil: true
|
||
|
||
validate :validate_awakening_level, on: :update
|
||
validate :transcendence, on: :update
|
||
validate :validate_over_mastery_values, on: :update
|
||
validate :validate_aetherial_mastery_value, on: :update
|
||
|
||
# Virtual attributes
|
||
attr_accessor :new_rings
|
||
attr_accessor :new_awakening
|
||
|
||
##### 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 }
|
||
set ring4: { modifier: nil, strength: nil }
|
||
set earring: { modifier: nil, strength: nil }
|
||
set perpetuity: false
|
||
end
|
||
|
||
# Hooks
|
||
before_validation :apply_new_rings, if: -> { new_rings.present? }
|
||
before_validation :apply_new_awakening, if: -> { new_awakening.present? }
|
||
before_save :add_awakening
|
||
|
||
##
|
||
# Validates the awakening level to ensure it falls within the allowed range.
|
||
#
|
||
# @note Triggered on update.
|
||
# @return [void]
|
||
def validate_awakening_level
|
||
errors.add(:awakening, 'awakening level too low') if awakening_level < 1
|
||
errors.add(:awakening, 'awakening level too high') if awakening_level > 9
|
||
end
|
||
|
||
##
|
||
# Validates the transcendence step of the character.
|
||
#
|
||
# Ensures that the transcendence step is appropriate based on the character's ULB status.
|
||
# Adds errors if:
|
||
# - The character has a positive transcendence_step but no transcendence (ulb is false).
|
||
# - The transcendence_step exceeds the allowed maximum.
|
||
# - The transcendence_step is negative when character.ulb is true.
|
||
#
|
||
# @note Triggered on update.
|
||
# @return [void]
|
||
def transcendence
|
||
return if transcendence_step.nil?
|
||
errors.add(:transcendence_step, 'character has no transcendence') if transcendence_step.positive? && !character.ulb
|
||
errors.add(:transcendence_step, 'transcendence step too high') if transcendence_step > 5 && character.ulb
|
||
errors.add(:transcendence_step, 'transcendence step too low') if transcendence_step.negative? && character.ulb
|
||
end
|
||
|
||
##
|
||
# Validates the over mastery attack value for ring1.
|
||
#
|
||
# Checks that if ring1's modifier is set, the strength must be one of the allowed attack values.
|
||
# Adds an error if the value is not valid.
|
||
#
|
||
# @return [void]
|
||
def over_mastery_attack
|
||
errors.add(:ring1, 'invalid value') unless ring1['modifier'].nil? || atk_values.include?(ring1['strength'])
|
||
end
|
||
|
||
##
|
||
# Validates the over mastery HP value for ring2.
|
||
#
|
||
# If ring2's modifier is present, ensures that the strength is within the allowed HP values.
|
||
# Adds an error if the value is not valid.
|
||
#
|
||
# @return [void]
|
||
def over_mastery_hp
|
||
return if ring2['modifier'].nil?
|
||
|
||
errors.add(:ring2, 'invalid value') unless hp_values.include?(ring2['strength'])
|
||
end
|
||
|
||
##
|
||
# Validates over mastery values by invoking individual and cross-field validations.
|
||
#
|
||
# This method triggers:
|
||
# - Validation for individual over mastery values for rings 1-4.
|
||
# - Validation ensuring that ring1's attack and ring2's HP values are consistent.
|
||
#
|
||
# @return [void]
|
||
def validate_over_mastery_values
|
||
validate_individual_over_mastery_values
|
||
validate_over_mastery_attack_matches_hp
|
||
end
|
||
|
||
##
|
||
# Validates individual over mastery values for each ring (ring1 to ring4).
|
||
#
|
||
# Iterates over each ring and, if a modifier is present, uses a helper to verify that the associated strength
|
||
# is within the permitted range based on over mastery rules.
|
||
#
|
||
# @return [void]
|
||
def validate_individual_over_mastery_values
|
||
# Iterate over rings 1-4 and check each ring’s value.
|
||
[ring1, ring2, ring3, ring4].each_with_index do |ring, index|
|
||
next if ring['modifier'].nil?
|
||
modifier = over_mastery_modifiers[ring['modifier']]
|
||
# Use a helper to add errors if the value is out-of-range.
|
||
check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } }, 'over_mastery')
|
||
end
|
||
end
|
||
|
||
##
|
||
# Validates that the over mastery attack value matches the HP value appropriately.
|
||
#
|
||
# Converts ring1 and ring2 hashes to use indifferent access, and if either ring has a modifier set,
|
||
# checks that ring2's strength is exactly half of ring1's strength.
|
||
# Adds an error if the values do not match.
|
||
#
|
||
# @return [void]
|
||
def validate_over_mastery_attack_matches_hp
|
||
# Convert ring1 and ring2 to use indifferent access so that keys (symbols or strings)
|
||
# can be accessed uniformly.
|
||
r1 = ring1.with_indifferent_access
|
||
r2 = ring2.with_indifferent_access
|
||
# Only check if either ring has a modifier set.
|
||
if r1[:modifier].present? || r2[:modifier].present?
|
||
# Ensure that ring2's strength equals exactly half of ring1's strength.
|
||
unless r2[:strength].to_f == (r1[:strength].to_f / 2)
|
||
errors.add(:over_mastery, 'over mastery attack and hp values do not match')
|
||
end
|
||
end
|
||
end
|
||
|
||
##
|
||
# Validates the aetherial mastery value for the earring.
|
||
#
|
||
# If the earring's modifier is present and positive, it uses a helper method to check that the strength
|
||
# falls within the allowed range for aetherial mastery.
|
||
#
|
||
# @return [void]
|
||
def validate_aetherial_mastery_value
|
||
return if earring['modifier'].nil?
|
||
|
||
return unless earring['modifier'].positive?
|
||
|
||
modifier = aetherial_mastery_modifiers[earring['modifier']].to_sym
|
||
check_value({ "earring": { modifier => earring['strength'] } },
|
||
'aetherial_mastery')
|
||
end
|
||
|
||
##
|
||
# Returns the blueprint for rendering the grid character.
|
||
#
|
||
# @return [GridCharacterBlueprint] the blueprint class used for grid character representation.
|
||
def blueprint
|
||
GridCharacterBlueprint
|
||
end
|
||
|
||
##
|
||
# Syncs customizations from the linked collection character.
|
||
#
|
||
# Copies uncap level, transcendence, rings, earring, and awakening from the collection.
|
||
# No-op if no collection character is linked.
|
||
#
|
||
# @return [Boolean] true if sync was performed, false if no collection link
|
||
def sync_from_collection!
|
||
return false unless collection_character.present?
|
||
|
||
update!(
|
||
uncap_level: collection_character.uncap_level,
|
||
transcendence_step: collection_character.transcendence_step,
|
||
perpetuity: collection_character.perpetuity,
|
||
ring1: collection_character.ring1,
|
||
ring2: collection_character.ring2,
|
||
ring3: collection_character.ring3,
|
||
ring4: collection_character.ring4,
|
||
earring: collection_character.earring,
|
||
awakening_id: collection_character.awakening_id,
|
||
awakening_level: collection_character.awakening_level
|
||
)
|
||
true
|
||
end
|
||
|
||
##
|
||
# Checks if grid character is out of sync with collection.
|
||
#
|
||
# @return [Boolean] true if any customization differs from collection
|
||
def out_of_sync?
|
||
return false unless collection_character.present?
|
||
|
||
uncap_level != collection_character.uncap_level ||
|
||
transcendence_step != collection_character.transcendence_step ||
|
||
perpetuity != collection_character.perpetuity ||
|
||
ring1 != collection_character.ring1 ||
|
||
ring2 != collection_character.ring2 ||
|
||
ring3 != collection_character.ring3 ||
|
||
ring4 != collection_character.ring4 ||
|
||
earring != collection_character.earring ||
|
||
awakening_id != collection_character.awakening_id ||
|
||
awakening_level != collection_character.awakening_level
|
||
end
|
||
|
||
private
|
||
|
||
##
|
||
# Adds a default awakening to the character before saving if none is set.
|
||
#
|
||
# Retrieves the Awakening record with slug 'character-balanced' and assigns it.
|
||
#
|
||
# @return [void]
|
||
def add_awakening
|
||
return unless awakening.nil?
|
||
|
||
self.awakening = Awakening.where(slug: 'character-balanced').sole
|
||
end
|
||
|
||
##
|
||
# Applies new ring configurations from the virtual attribute +new_rings+.
|
||
#
|
||
# Expects +new_rings+ to be an array of hashes with keys "modifier" and "strength".
|
||
# Pads the array with default ring hashes to ensure there are exactly four rings, then assigns them to
|
||
# ring1, ring2, ring3, and ring4.
|
||
#
|
||
# @return [void]
|
||
def apply_new_rings
|
||
# Expect new_rings to be an array of hashes, e.g.,
|
||
# [{"modifier" => "1", "strength" => "1500"}, {"modifier" => "2", "strength" => "750"}]
|
||
default_ring = { 'modifier' => nil, 'strength' => nil }
|
||
rings_array = Array(new_rings).map(&:to_h)
|
||
# Pad with defaults so there are exactly four rings
|
||
rings_array.fill(default_ring, rings_array.size...4)
|
||
self.ring1 = rings_array[0]
|
||
self.ring2 = rings_array[1]
|
||
self.ring3 = rings_array[2]
|
||
self.ring4 = rings_array[3]
|
||
end
|
||
|
||
##
|
||
# Applies new awakening configuration from the virtual attribute +new_awakening+.
|
||
#
|
||
# Sets the +awakening_id+ and +awakening_level+ based on the provided hash.
|
||
#
|
||
# @return [void]
|
||
def apply_new_awakening
|
||
self.awakening_id = new_awakening[:id]
|
||
self.awakening_level = new_awakening[:level].present? ? new_awakening[:level].to_i : 1
|
||
end
|
||
|
||
##
|
||
# Checks that a given property value falls within the allowed range based on the specified mastery type.
|
||
#
|
||
# The +property+ parameter is expected to be a hash in the following format:
|
||
# { ring1: { atk: 300 } }
|
||
#
|
||
# Depending on the +type+, it validates against either over mastery or aetherial mastery values.
|
||
# Adds an error to the record if the value is not within the permitted range.
|
||
#
|
||
# @param property [Hash] the property hash containing the attribute and its value.
|
||
# @param type [String] the type of mastery validation to perform ('over_mastery' or 'aetherial_mastery').
|
||
# @return [void]
|
||
def check_value(property, type)
|
||
# Input format
|
||
# { ring1: { atk: 300 } }
|
||
|
||
key = property.keys.first
|
||
modifier = property[key].keys.first
|
||
|
||
return if modifier.nil?
|
||
|
||
case type
|
||
when 'over_mastery'
|
||
errors.add(key, 'invalid value') unless over_mastery_values.include?(key['strength'])
|
||
when 'aetherial_mastery'
|
||
errors.add(key, 'value too low') if aetherial_mastery_values[modifier][:min] > self[key]['strength']
|
||
errors.add(key, 'value too high') if aetherial_mastery_values[modifier][:max] < self[key]['strength']
|
||
end
|
||
end
|
||
|
||
##
|
||
# Returns a hash mapping over mastery modifier keys to their corresponding attribute names.
|
||
#
|
||
# @return [Hash{Integer => String}] mapping of modifier codes to attribute names.
|
||
def over_mastery_modifiers
|
||
{
|
||
1 => 'atk',
|
||
2 => 'hp',
|
||
3 => 'debuff_success',
|
||
4 => 'skill_cap',
|
||
5 => 'ca_dmg',
|
||
6 => 'ca_cap',
|
||
7 => 'stamina',
|
||
8 => 'enmity',
|
||
9 => 'crit',
|
||
10 => 'da',
|
||
11 => 'ta',
|
||
12 => 'def',
|
||
13 => 'heal',
|
||
14 => 'debuff_resist',
|
||
15 => 'dodge'
|
||
}
|
||
end
|
||
|
||
##
|
||
# Returns a hash containing allowed values for over mastery attributes.
|
||
#
|
||
# @return [Hash{Symbol => Array<Integer>}] mapping of attribute names to their valid values.
|
||
def over_mastery_values
|
||
{
|
||
atk: [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000],
|
||
hp: [150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500],
|
||
debuff_success: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||
skill_cap: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||
ca_dmg: [10, 12, 14, 16, 18, 20, 22, 24, 27, 30],
|
||
ca_cap: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||
crit: [10, 12, 14, 16, 18, 20, 22, 24, 27, 30],
|
||
enmity: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||
stamina: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||
def: [6, 7, 8, 9, 10, 12, 14, 16, 18, 20],
|
||
heal: [3, 6, 9, 12, 15, 18, 21, 24, 27, 30],
|
||
debuff_resist: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||
dodge: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||
da: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||
ta: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||
}
|
||
end
|
||
|
||
# Returns a hash mapping aetherial mastery modifier keys to their corresponding attribute names.
|
||
#
|
||
# @return [Hash{Integer => String}] mapping of aetherial mastery modifier codes to attribute names.
|
||
def aetherial_mastery_modifiers
|
||
{
|
||
1 => 'da',
|
||
2 => 'ta',
|
||
3 => 'ele_atk',
|
||
4 => 'ele_resist',
|
||
5 => 'stamina',
|
||
6 => 'enmity',
|
||
7 => 'supplemental',
|
||
8 => 'crit',
|
||
9 => 'counter_dodge',
|
||
10 => 'counter_dmg'
|
||
}
|
||
end
|
||
|
||
##
|
||
# Returns a hash containing allowed values for aetherial mastery attributes.
|
||
#
|
||
# @return [Hash{Symbol => Hash{Symbol => Integer}}] mapping of attribute names to their minimum and maximum values.
|
||
def aetherial_mastery_values
|
||
{
|
||
da: {
|
||
min: 10,
|
||
max: 17
|
||
},
|
||
ta: {
|
||
min: 5,
|
||
max: 12
|
||
},
|
||
ele_atk: {
|
||
min: 15,
|
||
max: 22
|
||
},
|
||
ele_resist: {
|
||
min: 5,
|
||
max: 12
|
||
},
|
||
stamina: {
|
||
min: 5,
|
||
max: 12
|
||
},
|
||
enmity: {
|
||
min: 5,
|
||
max: 12
|
||
},
|
||
supplemental: {
|
||
min: 5,
|
||
max: 12
|
||
},
|
||
crit: {
|
||
min: 18,
|
||
max: 35
|
||
},
|
||
counter_dodge: {
|
||
min: 5,
|
||
max: 12
|
||
},
|
||
counter_dmg: {
|
||
min: 10,
|
||
max: 17
|
||
}
|
||
}
|
||
end
|
||
|
||
##
|
||
# Returns an array of valid attack values for over mastery validation.
|
||
#
|
||
# @return [Array<Integer>] list of allowed attack values.
|
||
def atk_values
|
||
[300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000]
|
||
end
|
||
|
||
##
|
||
# Returns an array of valid HP values for over mastery validation.
|
||
#
|
||
# @return [Array<Integer>] list of allowed HP values.
|
||
def hp_values
|
||
[150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500]
|
||
end
|
||
end
|