class Character < ApplicationRecord belongs_to :user belongs_to :activity, optional: true belongs_to :location has_many :title_awards has_many :titles, through: :title_awards belongs_to :active_title, class_name: "Title", optional: true has_one :hearth has_many :equipment has_many :character_items has_many :learned_activities has_many :items, through: :character_items has_many :item_infixes has_many :character_skills has_many :conditions, through: :states has_many :states has_many :chat_messages has_many :monster_kills has_many :bazaar_orders validates :name, presence: true # TODO: Make defaults better. This has to allow nil so the `attribute` default works, and I don't like that. validates :rested_duration, numericality: { greater_than_or_equal_to: 0, only_integer: true }, allow_nil: true validates_length_of :name, maximum: 15, message: "can't be longer than 15 characters" validates_uniqueness_of :name, message: "is already being used" validates_format_of :name, with: /\A[a-z]+\z/i, message: "must consist of letters only" attribute :wounds, :integer, default: 0 attribute :rested_duration, :integer, default: 0 enum offensive_style: [:balanced, :precise, :brutal], _default: "balanced" enum defensive_style: [:centered, :elusive, :protective], _default: "centered" after_create :create_skills, :set_combat_styles after_create { Hearth.create(character: self) } def self.sorted_by_total_xp all.sort_by(&:total_xp).reverse end def self.sorted_by_total_level all.sort_by(&:total_level).reverse end def self.top_total_xp sorted_by_total_xp.first(5) end def self.top_total_level sorted_by_total_level.first(5) end def beastslay_level; skill_level("beastslay"); end def fluxseethe_level; skill_level("fluxseethe"); end def havencast_level; skill_level("havencast"); end def hexcarve_level; skill_level("hexcarve"); end def magiculture_level; skill_level("magiculture"); end def manatrawl_level; skill_level("manatrawl"); end def omenbind_level; skill_level("omenbind"); end def otherforge_level; skill_level("otherforge"); end def planequarry_level; skill_level("planequarry"); end def spicework_level; skill_level("spicework"); end def synthsever_level; skill_level("synthsever"); end def veilreach_level; skill_level("veilreach"); end def wealdreap_level; skill_level("wealdreap"); end def wildscour_level; skill_level("wildscour"); end def worldcall_level; skill_level("worldcall"); end def total_xp character_skills.sum(:xp).to_i end def total_level count = 0 character_skills.each do |cs| count += cs.level end count end def total_xp_rank Character.sorted_by_total_xp.map(&:id).index(self.id) + 1 end def total_level_rank Character.sorted_by_total_level.map(&:id).index(self.id) + 1 end def vestige vestige = self.character_items.find_by(item: Item.find_by_gid("vestige")) vestige ? vestige.quantity : 0 end def shift_item(item, amount) item = Item.find_by_gid(item) if item.is_a? String CharacterItem.transaction do ci = self.character_items.find_or_initialize_by(item: item) ci.increment(:quantity, amount) ci.save! end end def active_states self.states.all.select { |s| !s.expired? } end def pay_activity_cost CharacterItem.transaction do activity.whatnot[:cost]&.each do |cost| case cost[:type] when "item" self.shift_item(cost[:gid], -(cost[:quantity] || 1)) when "hearth_planting" hp = hearth.ripe_hearth_plantings_of(cost[:gid]).first raise HearthPlantingError unless hp hp.destroy end end end end def do_equipment_break_check(skill: nil) skill = Skill.find_by_gid(skill) if skill&.is_a? String # TODO: HACK: Should check other stats besides speed stat in the future. # TODO: HACK: May not want a chance to break if speed is _reduced_. Though no equipment does this yet. if skill threatened_equipment = equipment.all.select { |eq| eq.effects&.select { |ef| ef[:gid] == "#{skill.gid}_speed" }&.any? } else threatened_equipment = equipment.all end break_slot = Equipment.random_break_slot return nil unless break_slot broken_equipment = threatened_equipment.find { |eq| eq.slot == break_slot } return nil unless broken_equipment broken_equipment.destroy broken_equipment.item end def do_item_infix_break_check(skill:) skill = Skill.find_by_gid(skill) if skill.is_a? String return nil unless ItemInfix.break_check broken_ii = item_infixes.where(skill: skill).sample return nil unless broken_ii broken_ii.destroy broken_ii.item end def has_item?(item, quantity = 1) item = Item.find_by_gid(item) if item.is_a? String ci = self.character_items.find_by(item: item) ci && ci.quantity >= quantity end def equipment_with_gid(item) item = Item.find_by_gid(item) if item.is_a? String self.equipment.find_by(item: item) end def equipment_with_tag(tag) self.equipment.all.find { |e| e.item.has_tag?(tag) } end def open_slots_for(item) full_slots = self.equipment.map { |e| e.slot } item.equip_slots.reject { |slot| full_slots.include?(slot) } end def trophy_monster_gid equipment = self.equipment.joins(:item).where("items.gid like ?", "%_trophy").first if equipment equipment.item.gid.split("_trophy").first else nil end end def can_equip?(item) item.whatnot[:equip_requirements]&.each do |requirement| case requirement[:type] when "skill" return false unless self.skill_level(requirement[:gid]) >= requirement[:level] else raise "Invalid requirement type string (#{requirement[:type]})" end end true end def equip(item) Character.transaction do raise EquipmentError unless self.can_equip?(item) open_slots = self.open_slots_for(item) raise EquipmentError unless open_slots.any? self.shift_item(item, -1) self.equipment.create!(item: item, slot: open_slots.first) end end def unequip(slot) Character.transaction do equipment = self.equipment.find_by(slot: slot) raise EquipmentError unless equipment item = equipment.item equipment.destroy! self.shift_item(item, 1) end end def max_infixes(skill) skill = Skill.find_by_gid(skill) if skill.is_a? String 1 + (skill_level(skill) / 20).floor end def available_infixes(skill) skill = Skill.find_by_gid(skill) if skill.is_a? String max_infixes(skill) - item_infixes.where(skill: skill).count end def infix(item, skill) Character.transaction do shift_item(item, -1) item_infixes.create(item: item, skill: skill) end end def remove_infix(item_infix) Character.transaction do shift_item(item_infix.item, 1) item_infix.destroy end end def add_skill_xp(skill, amount) skill = Skill.find_by_gid(skill) if skill.is_a? String Character.transaction do cs = self.character_skills.find_by(skill: skill) cs.xp += amount cs.save! end end def skill_level(skill) skill = Skill.find_by_gid(skill) if skill.is_a? String self.character_skills.find_by(skill: skill).level end def activity_time_remaining return nil unless self.activity time = activity_duration - (Time.now - self.activity_started_at) time -= rested_duration_to_spend_on_activity if self.rested_duration > 0 time end def activity_duration return nil unless self.activity duration_data = self.activity.whatnot[:duration] duration = duration_data[:base] duration_data[:scaling]&.each do |scaling_entry| case scaling_entry[:type] when "skill" duration -= self.skill_level(scaling_entry[:gid]) * scaling_entry[:scale_value] when "stat" duration -= self.total_stat_change(scaling_entry[:gid]) * scaling_entry[:scale_value] else raise "Invalid scaling type." # TODO: Improve this end end [duration, duration_data[:minimum] || 10].max end def percentage_of_activity_completed (1 - (activity_time_remaining / (activity_duration - rested_duration_to_spend_on_activity))) * 100 end def rested_duration_to_spend_on_activity return nil unless self.activity [(activity_duration / 2).floor, self.rested_duration].min end def can_do_activity?(activity, ignore_cost: false, ignore_requirements: false) return false unless activity.innate? || self.learned_activities.exists?(activity: activity) unless ignore_cost activity.whatnot[:cost]&.each do |cost| case cost[:type] when "item" return false unless self.has_item?(cost[:gid], cost[:quantity] || 1) when "hearth_planting" return false unless hearth.ripe_hearth_plantings_of(cost[:gid]).first else raise "Invalid cost type string (#{cost[:type]})" end end end unless ignore_requirements activity.whatnot[:requirements]&.each do |requirement| case requirement[:type] when "equipment" if requirement[:tag] return false unless self.equipment_with_tag(requirement[:tag]) else return false unless self.equipment_with_gid(requirement[:gid]) end when "stat" # TODO: HACK: This won't work with built-in stats! Need to change this to work with power and whatnot. return false unless self.total_stat_change(requirement[:gid]) >= requirement[:value] when "skill" return false unless self.skill_level(requirement[:gid]) >= requirement[:level] when "hearth_amenity" return false unless self.hearth.has_amenity?(requirement[:gid], requirement[:level]) when "time_of_day" return false unless requirement[:times].include? World.time_of_day.to_s else raise "Invalid requirement type string (#{requirement[:type]})" end end end true end def start_activity(activity, queued_actions: nil) if self.can_do_activity?(activity) self.update(activity: activity, activity_started_at: Time.now, queued_actions: queued_actions) else false end end def stop_activity self.update(activity: nil, activity_started_at: nil, queued_actions: nil) end def resting? self.started_resting_at end def start_resting return false if self.started_resting_at stop_activity self.update(started_resting_at: Time.now) end def stop_resting return false unless self.started_resting_at Character.transaction do seconds_of_this_rest = (Time.now - self.started_resting_at).to_i new_rested_duration = [(seconds_of_this_rest + self.rested_duration), (60 * 60 * 12)].min self.update(started_resting_at: nil, rested_duration: new_rested_duration) end end def rested_until Time.now + rested_duration.seconds end def award_title(title) title = Title.find_by_gid(title) if title.is_a? String # TODO: Simplify these lines? return false if self.title_awards.exists?(title: title) self.title_awards.create(title: title) end def effects # TODO: Review this filter_map to see if it can be simplified hearth_amenity_effects = self.hearth.built_hearth_amenities.filter_map { |a| a.effects if a.effects } equipment_effects = self.equipment.filter_map { |a| a.effects if a.effects } state_effects = self.states.filter_map { |a| a.effects if a.effects && !a.expired? } item_infix_effects = self.item_infixes.filter_map { |a| a.effects if a.effects } (hearth_amenity_effects + equipment_effects + state_effects + item_infix_effects).flatten end def total_stat_change(gid) effects.filter_map { |e| e[:modifier] if e[:type] == "stat_change" && e[:gid] == gid }.sum end def total_enemy_stat_change(gid) effects.filter_map { |e| e[:modifier] if e[:type] == "enemy_stat_change" && e[:gid] == gid }.sum end def damage_ranges effects.filter_map { |e| { gid: e[:gid], min: e[:min], max: e[:max] } if e[:type] == "damage" } end def dots effects.filter_map { |e| { gid: e[:gid], min: e[:min], max: e[:max], message: e[:message] } if e[:type] == "dot" } end def planting_spots [total_stat_change("planting_spots"), 0].max end def can_fight? self.wounds < max_wounds end def beastslay_stat_modifier (beastslay_level / 2).ceil end def beastslay_stance_modifier (beastslay_level / 8).ceil end def resistance(damage_type) unless %w[slash pierce bash arcane fire frost lightning acid thunder radiant necrotic poison bleed physical energy].include?(damage_type) raise "Invalid damage type" end res = total_stat_change("#{damage_type}_resistance") if %w[slash pierce bash].include?(damage_type) res += resistance("physical") elsif %w[arcane fire frost lightning acid thunder radiant necrotic].include?(damage_type) res += resistance("energy") end res += beastslay_stance_modifier if centered? && !%w[physical energy].include?(damage_type) res += beastslay_stance_modifier * 2 if protective? && !%w[physical energy].include?(damage_type) res end def max_wounds [1 + total_stat_change("max_wounds"), 0].max end def max_hp [9 + self.beastslay_level + total_stat_change("max_hp"), 1].max end def speed beastslay_stat_modifier + total_stat_change("speed") end def accuracy(with_combat_style: false) base = beastslay_stat_modifier + total_stat_change("accuracy") if with_combat_style && self.precise? base += beastslay_stance_modifier * 2 elsif with_combat_style && self.balanced? base += beastslay_stance_modifier end base end def power(with_combat_style: false) base = beastslay_stat_modifier + total_stat_change("power") if with_combat_style && self.balanced? base += beastslay_stance_modifier elsif with_combat_style && self.brutal? base += beastslay_stance_modifier * 2 end base end def evasion(with_combat_style: false) base = beastslay_stat_modifier + total_stat_change("evasion") if with_combat_style && self.elusive? base += beastslay_stance_modifier * 2 elsif with_combat_style && self.centered? base += beastslay_stance_modifier end base end private def create_skills Skill.all.each { |skill| self.character_skills.create(skill: skill, xp: 0) } end def set_combat_styles self.update(offensive_style: :balanced, defensive_style: :centered) end end