forked from OpenNeo/impress
Matchu
26dfe13b0e
Yay thank you Owls team! I might also try to parse the dates too, the format seems to be "Owls: Limited Dyeworks - Dyeable Thru July 15"
816 lines
28 KiB
Ruby
816 lines
28 KiB
Ruby
require "async"
|
|
require "async/barrier"
|
|
|
|
class Item < ApplicationRecord
|
|
include PrettyParam
|
|
|
|
# We use the `type` column to mean something other than what Rails means!
|
|
self.inheritance_column = nil
|
|
|
|
SwfAssetType = 'object'
|
|
|
|
has_many :closet_hangers
|
|
has_one :contribution, :as => :contributed, :inverse_of => :contributed
|
|
has_one :nc_mall_record
|
|
has_many :parent_swf_asset_relationships, :as => :parent
|
|
has_many :swf_assets, :through => :parent_swf_asset_relationships
|
|
belongs_to :dyeworks_base_item, class_name: "Item",
|
|
default: -> { inferred_dyeworks_base_item }, optional: true
|
|
|
|
|
|
attr_writer :current_body_id, :owned, :wanted
|
|
|
|
NCRarities = [0, 500]
|
|
PAINTBRUSH_SET_DESCRIPTION = 'This item is part of a deluxe paint brush set!'
|
|
SPECIAL_COLOR_DESCRIPTION_REGEX =
|
|
/This item is only wearable by [a-zA-Z]+ painted ([a-zA-Z]+)\.|WARNING: This [a-zA-Z]+ can be worn by ([a-zA-Z]+) [a-zA-Z]+ ONLY!|If your Neopet is not painted ([a-zA-Z]+), it will not be able to wear this item\./
|
|
|
|
scope :newest, -> {
|
|
order(arel_table[:created_at].desc) if arel_table[:created_at]
|
|
}
|
|
|
|
scope :sitemap, -> { order([:id]).limit(49999) }
|
|
|
|
scope :with_closet_hangers, -> { joins(:closet_hangers) }
|
|
|
|
scope :name_includes, ->(value) {
|
|
Item.where("name LIKE ?", "%" + sanitize_sql_like(value) + "%")
|
|
}
|
|
scope :name_excludes, ->(value) {
|
|
Item.where("name NOT LIKE ?", "%" + sanitize_sql_like(value) + "%")
|
|
}
|
|
scope :is_nc, -> {
|
|
i = Item.arel_table
|
|
where(i[:rarity_index].in(Item::NCRarities).or(i[:is_manually_nc].eq(true)))
|
|
}
|
|
scope :is_not_nc, -> {
|
|
i = Item.arel_table
|
|
where(i[:rarity_index].in(Item::NCRarities).or(i[:is_manually_nc].eq(true)).not)
|
|
}
|
|
scope :is_np, -> {
|
|
self.is_not_nc.is_not_pb
|
|
}
|
|
scope :is_not_np, -> {
|
|
self.merge Item.is_nc.or(Item.is_pb)
|
|
}
|
|
scope :is_pb, -> {
|
|
where('description LIKE ?',
|
|
'%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%')
|
|
}
|
|
scope :is_not_pb, -> {
|
|
where('description NOT LIKE ?',
|
|
'%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%')
|
|
}
|
|
scope :occupies, ->(zone_label) {
|
|
zone_ids = Zone.matching_label(zone_label).map(&:id)
|
|
|
|
# NOTE: In searches, this query performs much better using a subquery
|
|
# instead of joins! This is because, in the joins case, filtering by an
|
|
# `swf_assets` field but sorting by an `items` field causes the query
|
|
# planner to only be able to use an index for *one* of them. In this case,
|
|
# MySQL can use the `swf_assets`.`zone_id` index to get the item IDs for
|
|
# the subquery, then use the `items`.`name` index to sort them.
|
|
i = arel_table
|
|
psa = ParentSwfAssetRelationship.arel_table
|
|
sa = SwfAsset.arel_table
|
|
where(
|
|
ParentSwfAssetRelationship.joins(:swf_asset).
|
|
where(sa[:zone_id].in(zone_ids)).
|
|
where(psa[:parent_type].eq("Item")).
|
|
where(psa[:parent_id].eq(i[:id])).
|
|
arel.exists
|
|
)
|
|
}
|
|
scope :not_occupies, ->(zone_label) {
|
|
zone_ids = Zone.matching_label(zone_label).map(&:id)
|
|
i = Item.arel_table
|
|
sa = SwfAsset.arel_table
|
|
# Querying for "has NO swf_assets matching these zone IDs" is trickier than
|
|
# the positive case! To do it, we GROUP_CONCAT the zone_ids together for
|
|
# each item, then use FIND_IN_SET to search the result for each zone ID,
|
|
# and assert that it must not find a match. (This is uhh, not exactly fast,
|
|
# so it helps to have other tighter conditions applied first!)
|
|
# TODO: I feel like this could also be solved with a LEFT JOIN, idk if that
|
|
# performs any better? In Rails 5+ `left_outer_joins` is built in so!
|
|
condition = zone_ids.map { 'FIND_IN_SET(?, GROUP_CONCAT(zone_id)) = 0' }.join(' AND ')
|
|
joins(:swf_assets).group(i[:id]).having(condition, *zone_ids).distinct
|
|
}
|
|
scope :restricts, ->(zone_label) {
|
|
zone_ids = Zone.matching_label(zone_label).map(&:id)
|
|
condition = zone_ids.map { '(SUBSTR(items.zones_restrict, ?, 1) = "1")' }.join(' OR ')
|
|
where(condition, *zone_ids)
|
|
}
|
|
scope :not_restricts, ->(zone_label) {
|
|
zone_ids = Zone.matching_label(zone_label).map(&:id)
|
|
condition = zone_ids.map { '(SUBSTR(items.zones_restrict, ?, 1) = "1")' }.join(' OR ')
|
|
where("NOT (#{condition})", *zone_ids)
|
|
}
|
|
scope :fits, ->(body_id) {
|
|
joins(:swf_assets).where(swf_assets: {body_id: [body_id, 0]}).distinct
|
|
}
|
|
scope :not_fits, ->(body_id) {
|
|
i = Item.arel_table
|
|
sa = SwfAsset.arel_table
|
|
# Querying for "has NO swf_assets matching these body IDs" is trickier than
|
|
# the positive case! To do it, we GROUP_CONCAT the body_ids together for
|
|
# each item, then use FIND_IN_SET to search the result for the body ID,
|
|
# and assert that it must not find a match. (This is uhh, not exactly fast,
|
|
# so it helps to have other tighter conditions applied first!)
|
|
#
|
|
# TODO: I feel like this could also be solved with a LEFT JOIN, idk if that
|
|
# performs any better? In Rails 5+ `left_outer_joins` is built in so!
|
|
#
|
|
# NOTE: The `fits` and `not_fits` counts don't perfectly add up to the
|
|
# total number of items, 5 items aren't accounted for? I'm not going to
|
|
# bother looking into this, but one thing I notice is items with no assets
|
|
# somehow would not match either scope in this impl (but LEFT JOIN would!)
|
|
joins(:swf_assets).group(i[:id]).
|
|
having(
|
|
"FIND_IN_SET(?, GROUP_CONCAT(body_id)) = 0 AND " +
|
|
"FIND_IN_SET(0, GROUP_CONCAT(body_id)) = 0",
|
|
body_id
|
|
).
|
|
distinct
|
|
}
|
|
|
|
def nc_trade_value
|
|
return nil unless nc?
|
|
|
|
# Load the trade value, if we haven't already. Note that, because the trade
|
|
# value may be nil, we also save an explicit boolean for whether we've
|
|
# already looked it up, rather than checking if the saved value is empty.
|
|
return @nc_trade_value if @nc_trade_value_loaded
|
|
|
|
@nc_trade_value = begin
|
|
Rails.logger.debug "Item #{id} (#{name}) <lookup>"
|
|
OwlsValueGuide.find_by_name(name)
|
|
rescue OwlsValueGuide::NotFound => error
|
|
Rails.logger.debug("No NC trade value listed for #{name} (#{id})")
|
|
nil
|
|
rescue OwlsValueGuide::NetworkError => error
|
|
Rails.logger.error("Couldn't load nc_trade_value: #{error.full_message}")
|
|
nil
|
|
end
|
|
|
|
@nc_trade_value_loaded = true
|
|
@nc_trade_value
|
|
end
|
|
|
|
# Return an OrderedHash mapping users to the number of times they
|
|
# contributed to this item's assets, from most contributions to least.
|
|
def contributors_with_counts
|
|
# Get contributing users' IDs
|
|
swf_asset_ids = swf_assets.select(SwfAsset.arel_table[:id]).map(&:id)
|
|
swf_asset_contributions = Contribution.select('user_id').
|
|
where(:contributed_type => 'SwfAsset', :contributed_id => swf_asset_ids)
|
|
contributor_ids = swf_asset_contributions.map(&:user_id)
|
|
|
|
# Get the users, mapped by ID
|
|
contributors_by_id = {}
|
|
User.find(contributor_ids).each { |u| contributors_by_id[u.id] = u }
|
|
|
|
# Count each user's contributions
|
|
contributor_counts_by_id = Hash.new(0)
|
|
contributor_ids.each { |id| contributor_counts_by_id[id] += 1 }
|
|
|
|
# Build an OrderedHash mapping users to counts in descending order
|
|
contributors_with_counts = ActiveSupport::OrderedHash.new
|
|
contributor_counts_by_id.sort_by { |k, v| v }.reverse.each do |id, count|
|
|
contributor = contributors_by_id[id]
|
|
contributors_with_counts[contributor] = count
|
|
end
|
|
contributors_with_counts
|
|
end
|
|
|
|
def nc?
|
|
is_manually_nc? || NCRarities.include?(rarity_index)
|
|
end
|
|
|
|
def pb?
|
|
I18n.with_locale(:en) { self.description == PAINTBRUSH_SET_DESCRIPTION }
|
|
end
|
|
|
|
def np?
|
|
!nc? && !pb?
|
|
end
|
|
|
|
def currently_in_mall?
|
|
nc_mall_record.present?
|
|
end
|
|
|
|
def dyeworks?
|
|
dyeworks_base_item.present?
|
|
end
|
|
|
|
# Whether this is a Dyeworks item whose base item can currently be purchased
|
|
# in the NC Mall. It may or may not currently be *dyeable* in the NC Mall,
|
|
# because Dyeworks eligibility is often a limited-time event.
|
|
def dyeworks_base_buyable?
|
|
dyeworks_base_item.present? && dyeworks_base_item.currently_in_mall?
|
|
end
|
|
|
|
# Whether this is one of the few Dyeworks items that can be dyed in the NC
|
|
# Mall at any time, rather than as part of a limited-time event. (Owls tracks
|
|
# this, not us!)
|
|
def dyeworks_permanent?
|
|
return false if nc_trade_value.nil?
|
|
nc_trade_value.value_text.include?("Permanent Dyeworks")
|
|
end
|
|
|
|
# Whether this is a Dyeworks item that can be dyed in the NC Mall ~right now,
|
|
# but only as part of a limited-time event. (Owls tracks this, not us!)
|
|
def dyeworks_limited?
|
|
return false if nc_trade_value.nil?
|
|
nc_trade_value.value_text.include?("Limited Dyeworks")
|
|
end
|
|
|
|
# Whether this is a Dyeworks item that can be dyed in the NC Mall ~right now,
|
|
# either at any time or as a limited-time event. (Owls tracks this, not us!)
|
|
def dyeworks_dyeable?
|
|
dyeworks_permanent? || dyeworks_limited?
|
|
end
|
|
|
|
# Whether this is a Dyeworks item whose base item can currently be purchased
|
|
# in the NC Mall, then dyed via Dyeworks. (Owls tracks this last part!)
|
|
def dyeworks_buyable?
|
|
dyeworks_base_buyable? && dyeworks_dyeable?
|
|
end
|
|
|
|
DYEWORKS_NAME_PATTERN = %r{
|
|
^(
|
|
# Most Dyeworks items have a colon in the name.
|
|
Dyeworks\s+(?<color>.+?:)\s*(?<base>.+)
|
|
|
|
|
# But sometimes they omit it. If so, assume the first word is the color!
|
|
Dyeworks\s+(?<color>\S+)\s*(?<base>.+)
|
|
)$
|
|
}x
|
|
def inferred_dyeworks_base_item
|
|
name_match = name.match(DYEWORKS_NAME_PATTERN)
|
|
return nil if name_match.nil?
|
|
|
|
Item.find_by_name(name_match["base"])
|
|
end
|
|
|
|
def source
|
|
if dyeworks_buyable?
|
|
:dyeworks
|
|
elsif currently_in_mall?
|
|
:nc_mall
|
|
elsif nc?
|
|
:other_nc
|
|
elsif np?
|
|
:np
|
|
elsif pb?
|
|
:pb
|
|
else
|
|
raise "Item has no matching source (should not happen?)"
|
|
end
|
|
end
|
|
|
|
def owned?
|
|
@owned || false
|
|
end
|
|
|
|
def wanted?
|
|
@wanted || false
|
|
end
|
|
|
|
def current_nc_price
|
|
nc_mall_record&.current_price
|
|
end
|
|
|
|
# If this is a PB item, return the corresponding Color, inferred from the
|
|
# item name. If it's not a PB item, or we fail to infer a specific color,
|
|
# return nil. (This is expected to be nil for some PB items, like the "Aisha
|
|
# Collar", which belong to many colors. It can also be nil for PB items for
|
|
# new colors we haven't manually added to the database yet, or if a PB item
|
|
# is named strangely in the future.)
|
|
def pb_color
|
|
return nil unless pb?
|
|
|
|
# NOTE: To handle colors like "Royalboy", where the items aren't consistent
|
|
# with the color name regarding whether or not there's spaces, we remove
|
|
# all spaces from the item name and color name when matching. We also
|
|
# hackily handle the fact that "Elderlyboy" color has items named "Elderly
|
|
# Male" (and same for Girl/Female) by replacing those words, too. These
|
|
# hacks could cause false matches in theory, but I'm not aware of any rn!
|
|
normalized_name = name.downcase.gsub("female", "girl").gsub("male", "boy").
|
|
gsub(/\s/, "")
|
|
|
|
Color.order(:name).
|
|
find { |c| normalized_name.include?(c.name.downcase.gsub(/\s/, "")) }
|
|
end
|
|
|
|
# If this is a PB item, return the corresponding Species, inferred from the
|
|
# item name. If it's not a PB item, or we fail to infer a specific species,
|
|
# return nil. (This is not expected to be nil in general, but could be for PB
|
|
# items for new species we haven't manually added to the database yet, or if
|
|
# a PB item is named strangely in the future.)
|
|
def pb_species
|
|
return nil unless pb?
|
|
normalized_name = name.downcase
|
|
Species.order(:name).find { |s| normalized_name.include?(s.name.downcase) }
|
|
end
|
|
|
|
def pb_item_name
|
|
pb_color&.pb_item_name
|
|
end
|
|
|
|
def restricted_zones(options={})
|
|
options[:scope] ||= Zone.all
|
|
options[:scope].find(restricted_zone_ids)
|
|
end
|
|
|
|
def restricted_zone_ids
|
|
unless @restricted_zone_ids
|
|
@restricted_zone_ids = []
|
|
zones_restrict.split(//).each_with_index do |switch, id|
|
|
@restricted_zone_ids << (id.to_i + 1) if switch == '1'
|
|
end
|
|
end
|
|
@restricted_zone_ids
|
|
end
|
|
|
|
def occupied_zone_ids
|
|
occupied_zones.map(&:id)
|
|
end
|
|
|
|
def occupied_zones(options={})
|
|
options[:scope] ||= Zone.all
|
|
all_body_ids = []
|
|
zone_body_ids = {}
|
|
selected_assets = swf_assets.select('body_id, zone_id').each do |swf_asset|
|
|
zone_body_ids[swf_asset.zone_id] ||= []
|
|
body_ids = zone_body_ids[swf_asset.zone_id]
|
|
body_ids << swf_asset.body_id unless body_ids.include?(swf_asset.body_id)
|
|
all_body_ids << swf_asset.body_id unless all_body_ids.include?(swf_asset.body_id)
|
|
end
|
|
zones = options[:scope].find(zone_body_ids.keys)
|
|
zones_by_id = zones.inject({}) { |h, z| h[z.id] = z; h }
|
|
total_body_ids = all_body_ids.size
|
|
zone_body_ids.each do |zone_id, body_ids|
|
|
zones_by_id[zone_id].sometimes = true if body_ids.size < total_body_ids
|
|
end
|
|
zones
|
|
end
|
|
|
|
def affected_zones
|
|
restricted_zones + occupied_zones
|
|
end
|
|
|
|
def special_color
|
|
@special_color ||= determine_special_color
|
|
end
|
|
|
|
def special_color_id
|
|
special_color.try(:id)
|
|
end
|
|
|
|
protected
|
|
def determine_special_color
|
|
I18n.with_locale(I18n.default_locale) do
|
|
# Rather than go find the special description in all locales, let's just
|
|
# run this logic in English.
|
|
if description.include?(PAINTBRUSH_SET_DESCRIPTION)
|
|
name_words = name.downcase.split
|
|
Color.nonstandard.each do |color|
|
|
return color if name_words.include?(color.name)
|
|
end
|
|
end
|
|
|
|
match = description.match(SPECIAL_COLOR_DESCRIPTION_REGEX)
|
|
if match
|
|
# Since there are multiple formats in the one regex, there are multiple
|
|
# possible color name captures. So, take the first non-nil capture.
|
|
color = match.captures.detect(&:present?)
|
|
return Color.find_by_name(color.downcase)
|
|
end
|
|
|
|
# HACK: this should probably be a flag on the record instead of
|
|
# being hardcoded :P
|
|
if [71893, 76192, 76202, 77367, 77368, 77369, 77370].include?(id)
|
|
return Color.find_by_name('baby')
|
|
end
|
|
|
|
if [76198].include?(id)
|
|
return Color.find_by_name('mutant')
|
|
end
|
|
|
|
if [75372].include?(id)
|
|
return Color.find_by_name('maraquan')
|
|
end
|
|
|
|
if manual_special_color_id?
|
|
return Color.find(manual_special_color_id)
|
|
end
|
|
end
|
|
end
|
|
public
|
|
|
|
def species_support_ids
|
|
@species_support_ids_array ||= read_attribute('species_support_ids').split(',').map(&:to_i) rescue nil
|
|
end
|
|
|
|
def species_support_ids=(replacement)
|
|
@species_support_ids_array = nil
|
|
replacement = replacement.join(',') if replacement.is_a?(Array)
|
|
write_attribute('species_support_ids', replacement)
|
|
end
|
|
|
|
def support_species?(species)
|
|
species_support_ids.blank? || species_support_ids.include?(species.id)
|
|
end
|
|
|
|
def modeled_body_ids
|
|
@modeled_body_ids ||= swf_assets.select('DISTINCT body_id').map(&:body_id)
|
|
end
|
|
|
|
def modeled_color_ids
|
|
# Might be empty if modeled_body_ids is 0. But it's currently not called
|
|
# in that scenario, so, whatever.
|
|
@modeled_color_ids ||= PetType.select('DISTINCT color_id').
|
|
where(body_id: modeled_body_ids).
|
|
map(&:color_id)
|
|
end
|
|
|
|
def basic_body_ids
|
|
@basic_body_ids ||= begin
|
|
basic_color_ids ||= Color.select([:id]).basic.map(&:id)
|
|
PetType.select('DISTINCT body_id').
|
|
where(color_id: basic_color_ids).map(&:body_id)
|
|
end
|
|
end
|
|
|
|
def predicted_body_ids
|
|
@predicted_body_ids ||= if modeled_body_ids.include?(0)
|
|
# Oh, look, it's already known to fit everybody! Sweet. We're done. (This
|
|
# isn't folded into the case below, in case this item somehow got a
|
|
# body-specific and non-body-specific asset. In all the cases I've seen
|
|
# it, that indicates a glitched item, but this method chooses to reflect
|
|
# behavior elsewhere in the app by saying that we can put this item on
|
|
# anybody. (Heh. Any body.))
|
|
modeled_body_ids
|
|
elsif modeled_body_ids.size == 1
|
|
# This might just be a species-specific item. Let's be conservative in
|
|
# our prediction, though we'll revise it if we see another body ID.
|
|
modeled_body_ids
|
|
else
|
|
# If an item is worn by more than one body, then it must be wearable by
|
|
# all bodies of the same color. (To my knowledge, anyway. I'm not aware
|
|
# of any exceptions.) So, let's find those bodies by first finding those
|
|
# colors.
|
|
basic_modeled_body_ids, nonbasic_modeled_body_ids = modeled_body_ids.
|
|
partition { |bi| basic_body_ids.include?(bi) }
|
|
|
|
output = []
|
|
if basic_modeled_body_ids.present?
|
|
output += basic_body_ids
|
|
end
|
|
if nonbasic_modeled_body_ids.present?
|
|
nonbasic_modeled_color_ids = PetType.select('DISTINCT color_id').
|
|
where(body_id: nonbasic_modeled_body_ids).
|
|
map(&:color_id)
|
|
output += PetType.select('DISTINCT body_id').
|
|
where(color_id: nonbasic_modeled_color_ids).
|
|
map(&:body_id)
|
|
end
|
|
output
|
|
end
|
|
end
|
|
|
|
def predicted_missing_body_ids
|
|
@predicted_missing_body_ids ||= predicted_body_ids - modeled_body_ids
|
|
end
|
|
|
|
def predicted_missing_standard_body_ids_by_species_id
|
|
@predicted_missing_standard_body_ids_by_species_id ||=
|
|
PetType.select('DISTINCT body_id, species_id').
|
|
joins(:color).
|
|
where(body_id: predicted_missing_body_ids,
|
|
colors: {standard: true}).
|
|
inject({}) { |h, pt| h[pt.species_id] = pt.body_id; h }
|
|
end
|
|
|
|
def predicted_missing_standard_body_ids_by_species
|
|
species = Species.where(id: predicted_missing_standard_body_ids_by_species_id.keys)
|
|
species_by_id = species.inject({}) { |h, s| h[s.id] = s; h }
|
|
predicted_missing_standard_body_ids_by_species_id.inject({}) { |h, (sid, bid)|
|
|
h[species_by_id[sid]] = bid; h }
|
|
end
|
|
|
|
def predicted_missing_nonstandard_body_pet_types
|
|
PetType.joins(:color).
|
|
where(body_id: predicted_missing_body_ids - basic_body_ids,
|
|
colors: {standard: false})
|
|
end
|
|
|
|
def predicted_missing_nonstandard_body_ids_by_species_by_color
|
|
pet_types = predicted_missing_nonstandard_body_pet_types
|
|
|
|
species_by_id = {}
|
|
Species.find(pet_types.map(&:species_id)).each do |species|
|
|
species_by_id[species.id] = species
|
|
end
|
|
|
|
colors_by_id = {}
|
|
Color.find(pet_types.map(&:color_id)).each do |color|
|
|
colors_by_id[color.id] = color
|
|
end
|
|
|
|
body_ids_by_species_by_color = {}
|
|
pet_types.each do |pt|
|
|
color = colors_by_id[pt.color_id]
|
|
body_ids_by_species_by_color[color] ||= {}
|
|
body_ids_by_species_by_color[color][species_by_id[pt.species_id]] = pt.body_id
|
|
end
|
|
body_ids_by_species_by_color
|
|
end
|
|
|
|
def predicted_fully_modeled?
|
|
predicted_missing_body_ids.empty?
|
|
end
|
|
|
|
def predicted_modeled_ratio
|
|
modeled_body_ids.size.to_f / predicted_body_ids.size
|
|
end
|
|
|
|
def as_json(options={})
|
|
super({
|
|
only: [:id, :name, :description, :thumbnail_url, :rarity_index],
|
|
methods: [:zones_restrict],
|
|
}.merge(options))
|
|
end
|
|
|
|
def handle_assets!
|
|
if @parent_swf_asset_relationships_to_update && @current_body_id
|
|
new_swf_asset_ids = @parent_swf_asset_relationships_to_update.map(&:swf_asset_id)
|
|
rels = ParentSwfAssetRelationship.arel_table
|
|
swf_assets = SwfAsset.arel_table
|
|
|
|
# If a relationship used to bind an item and asset for this body type,
|
|
# but doesn't in this sample, the two have been unbound. Delete the
|
|
# relationship.
|
|
ids_to_delete = self.parent_swf_asset_relationships.
|
|
select(rels[:id]).
|
|
joins(:swf_asset).
|
|
where(rels[:swf_asset_id].not_in(new_swf_asset_ids)).
|
|
where(swf_assets[:body_id].in([@current_body_id, 0])).
|
|
map(&:id)
|
|
|
|
unless ids_to_delete.empty?
|
|
ParentSwfAssetRelationship.where(:id => ids_to_delete).delete_all
|
|
end
|
|
|
|
@parent_swf_asset_relationships_to_update.each do |rel|
|
|
rel.save!
|
|
rel.swf_asset.save!
|
|
end
|
|
end
|
|
end
|
|
|
|
def body_specific?
|
|
# If there are species support IDs (it's not empty), the item is
|
|
# body-specific. If it's empty, it fits everyone the same.
|
|
explicitly_body_specific? || !species_support_ids.empty?
|
|
end
|
|
|
|
def add_origin_registry_info(info, locale)
|
|
# bear in mind that numbers from registries are floats
|
|
species_support_strs = info['species_support'] || []
|
|
self.species_support_ids = species_support_strs.map(&:to_i)
|
|
|
|
# NOTE: If some of these fields are missing, it could cause saving the item
|
|
# to fail, because many of these columns are non-nullable.
|
|
self.name = info['name']
|
|
self.description = info['description']
|
|
self.thumbnail_url = info['thumbnail_url']
|
|
self.category = info['category']
|
|
self.type = info['type']
|
|
self.rarity = info['rarity']
|
|
self.rarity_index = info['rarity_index'].to_i
|
|
self.price = info['price'].to_i
|
|
self.weight_lbs = info['weight_lbs'].to_i
|
|
self.zones_restrict = info['zones_restrict']
|
|
end
|
|
|
|
def pending_swf_assets
|
|
@parent_swf_asset_relationships_to_update.inject([]) do |all_swf_assets, relationship|
|
|
all_swf_assets << relationship.swf_asset
|
|
end
|
|
end
|
|
|
|
def parent_swf_asset_relationships_to_update=(rels)
|
|
@parent_swf_asset_relationships_to_update = rels
|
|
end
|
|
|
|
# NOTE: Adding the JSON serializer makes `as_json` treat this like a model
|
|
# instead of like a hash, so you can target its children with things like
|
|
# the `include` option. This feels clunky though, I wish I had something a
|
|
# bit more suited to it!
|
|
Appearance = Struct.new(:body, :swf_assets) do
|
|
include ActiveModel::Serializers::JSON
|
|
def attributes
|
|
{body: body, swf_assets: swf_assets}
|
|
end
|
|
end
|
|
Appearance::Body = Struct.new(:id, :species) do
|
|
include ActiveModel::Serializers::JSON
|
|
def attributes
|
|
{id: id, species: species}
|
|
end
|
|
end
|
|
|
|
def appearances
|
|
all_swf_assets = swf_assets.to_a
|
|
|
|
# If there are no assets yet, there are no appearances.
|
|
return [] if all_swf_assets.empty?
|
|
|
|
# Get all SWF assets, and separate the ones that fit everyone (body_id=0).
|
|
swf_assets_by_body_id = all_swf_assets.group_by(&:body_id)
|
|
swf_assets_for_all_bodies = swf_assets_by_body_id.delete(0) || []
|
|
|
|
# If there are no body-specific assets, return one appearance for them all.
|
|
if swf_assets_by_body_id.empty?
|
|
body = Appearance::Body.new(0, nil)
|
|
return [Appearance.new(body, swf_assets_for_all_bodies)]
|
|
end
|
|
|
|
# Otherwise, create an appearance for each real (nonzero) body ID. We don't
|
|
# generally expect body_id = 0 and body_id != 0 to mix, but if they do,
|
|
# uhh, let's merge the body_id = 0 ones in?
|
|
swf_assets_by_body_id.map do |body_id, body_specific_assets|
|
|
swf_assets_for_body = body_specific_assets + swf_assets_for_all_bodies
|
|
species = Species.with_body_id(body_id).first!
|
|
body = Appearance::Body.new(body_id, species)
|
|
Appearance.new(body, swf_assets_for_body)
|
|
end
|
|
end
|
|
|
|
# Given a list of item IDs, return how they look on the given target (either
|
|
# a pet type or an alt style).
|
|
def self.appearances_for(item_ids, target, swf_asset_includes: [])
|
|
# First, load all the relationships for these items that also fit this
|
|
# body.
|
|
relationships = ParentSwfAssetRelationship.
|
|
includes(swf_asset: swf_asset_includes).
|
|
where(parent_type: "Item", parent_id: item_ids).
|
|
where(swf_asset: {body_id: [target.body_id, 0]})
|
|
|
|
pet_type_body = Appearance::Body.new(target.body_id, target.species)
|
|
all_pets_body = Appearance::Body.new(0, nil)
|
|
|
|
# Then, convert this into a hash from item ID to SWF assets.
|
|
assets_by_item_id = relationships.group_by(&:parent_id).
|
|
transform_values { |rels| rels.map(&:swf_asset) }
|
|
|
|
# Finally, for each item, return an appearance—even if it's empty!
|
|
item_ids.to_h do |item_id|
|
|
assets = assets_by_item_id.fetch(item_id, [])
|
|
|
|
fits_all_pets = assets.present? && assets.all? { |a| a.body_id == 0 }
|
|
body = fits_all_pets ? all_pets_body : pet_type_body
|
|
|
|
[item_id, Appearance.new(body, assets)]
|
|
end
|
|
end
|
|
|
|
def self.all_by_ids_or_children(ids, swf_assets)
|
|
swf_asset_ids = []
|
|
swf_assets_by_id = {}
|
|
swf_assets_by_parent_id = {}
|
|
swf_assets.each do |swf_asset|
|
|
id = swf_asset.id
|
|
swf_assets_by_id[id] = swf_asset
|
|
swf_asset_ids << id
|
|
end
|
|
SwfAsset.object_assets.joins(:parent_swf_asset_relationships).
|
|
where(SwfAsset.arel_table[:id].in(swf_asset_ids)).select([
|
|
SwfAsset.arel_table[:id],
|
|
ParentSwfAssetRelationship.arel_table[:parent_id]
|
|
]).each do |row|
|
|
item_id = row.parent_id.to_i
|
|
swf_assets_by_parent_id[item_id] ||= []
|
|
swf_assets_by_parent_id[item_id] << swf_assets_by_id[row.id.to_i]
|
|
ids << item_id
|
|
end
|
|
find(ids).tap do |items|
|
|
items.each do |item|
|
|
swf_assets = swf_assets_by_parent_id[item.id]
|
|
if swf_assets
|
|
swf_assets.each do |swf_asset|
|
|
swf_asset.item = item
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.preload_nc_trade_values(items)
|
|
# Only allow 10 trade values to be loaded at a time.
|
|
barrier = Async::Barrier.new
|
|
semaphore = Async::Semaphore.new(10, parent: barrier)
|
|
|
|
Sync do
|
|
# Load all the trade values in concurrent async tasks. (The
|
|
# `nc_trade_value` caches the value in the Item object.)
|
|
items.each do |item|
|
|
semaphore.async { item.nc_trade_value }
|
|
end
|
|
|
|
# Wait until all tasks are done.
|
|
barrier.wait
|
|
ensure
|
|
barrier.stop # If something goes wrong, clean up all tasks.
|
|
end
|
|
|
|
items
|
|
end
|
|
|
|
def self.collection_from_pet_type_and_registries(pet_type, info_registry, asset_registry, scope=Item.all)
|
|
# bear in mind that registries are arrays with many nil elements,
|
|
# due to how the parser works
|
|
|
|
# Collect existing items
|
|
items = {}
|
|
item_ids = []
|
|
info_registry.each do |item_id, info|
|
|
if info && info[:is_compatible]
|
|
item_ids << item_id.to_i
|
|
end
|
|
end
|
|
|
|
# Collect existing relationships
|
|
existing_relationships_by_item_id_and_swf_asset_id = {}
|
|
existing_items = scope.where(id: item_ids).
|
|
includes(:parent_swf_asset_relationships)
|
|
existing_items.each do |item|
|
|
items[item.id] = item
|
|
relationships_by_swf_asset_id = {}
|
|
item.parent_swf_asset_relationships.each do |relationship|
|
|
relationships_by_swf_asset_id[relationship.swf_asset_id] = relationship
|
|
end
|
|
existing_relationships_by_item_id_and_swf_asset_id[item.id] =
|
|
relationships_by_swf_asset_id
|
|
end
|
|
|
|
# Collect existing assets
|
|
swf_asset_ids = []
|
|
asset_registry.each do |asset_id, asset_data|
|
|
swf_asset_ids << asset_id.to_i if asset_data
|
|
end
|
|
existing_swf_assets = SwfAsset.object_assets.includes(:zone).
|
|
where(remote_id: swf_asset_ids)
|
|
existing_swf_assets_by_remote_id = {}
|
|
existing_swf_assets.each do |swf_asset|
|
|
existing_swf_assets_by_remote_id[swf_asset.remote_id] = swf_asset
|
|
end
|
|
|
|
# With each asset in the registry,
|
|
relationships_by_item_id = {}
|
|
asset_registry.each do |asset_id, asset_data|
|
|
if asset_data
|
|
# Build and update the item
|
|
item_id = asset_data[:obj_info_id].to_i
|
|
next unless item_ids.include?(item_id) # skip incompatible (Uni Bug)
|
|
item = items[item_id]
|
|
unless item
|
|
item = Item.new
|
|
item.id = item_id
|
|
items[item_id] = item
|
|
end
|
|
item.add_origin_registry_info info_registry[item.id.to_s], I18n.default_locale
|
|
item.current_body_id = pet_type.body_id
|
|
|
|
# Build and update the SWF
|
|
swf_asset_remote_id = asset_data[:asset_id].to_i
|
|
swf_asset = existing_swf_assets_by_remote_id[swf_asset_remote_id]
|
|
unless swf_asset
|
|
swf_asset = SwfAsset.new
|
|
swf_asset.remote_id = swf_asset_remote_id
|
|
end
|
|
swf_asset.origin_object_data = asset_data
|
|
swf_asset.origin_pet_type = pet_type
|
|
swf_asset.item = item
|
|
|
|
# Build and update the relationship
|
|
relationship = existing_relationships_by_item_id_and_swf_asset_id[item.id][swf_asset.id] rescue nil
|
|
unless relationship
|
|
relationship = ParentSwfAssetRelationship.new
|
|
relationship.parent = item
|
|
end
|
|
relationship.swf_asset = swf_asset
|
|
relationships_by_item_id[item_id] ||= []
|
|
relationships_by_item_id[item_id] << relationship
|
|
end
|
|
end
|
|
|
|
# Set up the relationships to be updated on item save
|
|
relationships_by_item_id.each do |item_id, relationships|
|
|
items[item_id].parent_swf_asset_relationships_to_update = relationships
|
|
end
|
|
|
|
items.values
|
|
end
|
|
end
|