2023-08-02 16:05:02 -07:00
|
|
|
class Outfit < ApplicationRecord
|
2010-11-12 13:31:01 -08:00
|
|
|
has_many :item_outfit_relationships, :dependent => :destroy
|
2023-07-29 12:13:58 -07:00
|
|
|
has_many :worn_item_outfit_relationships, -> { where(is_worn: true) },
|
2023-11-02 13:50:33 -07:00
|
|
|
class_name: 'ItemOutfitRelationship'
|
|
|
|
has_many :worn_items, through: :worn_item_outfit_relationships, source: :item
|
|
|
|
|
2024-02-01 05:55:19 -08:00
|
|
|
belongs_to :alt_style, optional: true
|
2023-11-02 13:50:33 -07:00
|
|
|
belongs_to :pet_state, optional: true # We validate presence below!
|
2023-08-02 17:57:53 -07:00
|
|
|
belongs_to :user, optional: true
|
2011-03-23 15:23:01 -07:00
|
|
|
|
2011-02-10 14:50:47 -08:00
|
|
|
validates :name, :presence => {:if => :user_id}, :uniqueness => {:scope => :user_id, :if => :user_id}
|
2023-11-02 13:50:33 -07:00
|
|
|
validates :pet_state, presence: {
|
|
|
|
message: ->(object, _) do
|
|
|
|
if object.biology
|
|
|
|
"does not exist for " +
|
|
|
|
"species ##{object.biology[:species_id]}, " +
|
|
|
|
"color ##{object.biology[:color_id]}, " +
|
|
|
|
"pose #{object.biology[:pose]}"
|
|
|
|
else
|
|
|
|
"must exist"
|
|
|
|
end
|
|
|
|
end
|
|
|
|
}
|
2011-03-23 15:23:01 -07:00
|
|
|
|
2023-11-02 13:50:33 -07:00
|
|
|
before_validation :ensure_unique_name, if: :user_id?
|
|
|
|
|
|
|
|
attr_reader :biology
|
2024-07-01 16:07:25 -07:00
|
|
|
delegate :pose, to: :pet_state
|
|
|
|
delegate :pet_type, to: :pet_state
|
|
|
|
delegate :color, to: :pet_type
|
|
|
|
delegate :color_id, to: :pet_type
|
|
|
|
delegate :species, to: :pet_type
|
|
|
|
delegate :species_id, to: :pet_type
|
2014-03-28 13:15:04 -07:00
|
|
|
|
2023-07-22 14:04:01 -07:00
|
|
|
scope :wardrobe_order, -> { order('starred DESC', :name) }
|
2012-03-15 15:00:29 -07:00
|
|
|
|
2021-05-24 20:05:25 -07:00
|
|
|
class OutfitImage
|
|
|
|
def initialize(image_versions)
|
|
|
|
@image_versions = image_versions
|
|
|
|
end
|
2011-03-23 15:23:01 -07:00
|
|
|
|
2021-05-24 20:05:25 -07:00
|
|
|
def url
|
|
|
|
@image_versions[:large]
|
|
|
|
end
|
|
|
|
|
|
|
|
def large
|
|
|
|
Version.new(@image_versions[:large])
|
|
|
|
end
|
|
|
|
|
|
|
|
def medium
|
|
|
|
Version.new(@image_versions[:medium])
|
|
|
|
end
|
|
|
|
|
|
|
|
def small
|
|
|
|
Version.new(@image_versions[:small])
|
|
|
|
end
|
|
|
|
|
|
|
|
Version = Struct.new(:url)
|
|
|
|
end
|
|
|
|
|
|
|
|
def image?
|
|
|
|
true
|
|
|
|
end
|
|
|
|
|
|
|
|
def image
|
|
|
|
OutfitImage.new(image_versions)
|
2012-07-17 09:15:04 -07:00
|
|
|
end
|
|
|
|
|
2024-02-08 10:03:10 -08:00
|
|
|
IMAGE_URL_TEMPLATE = Addressable::Template.new(
|
2024-02-22 13:07:43 -08:00
|
|
|
Rails.configuration.impress_2020_origin +
|
2024-02-08 10:03:10 -08:00
|
|
|
"/api/outfitImage{?id,size,updatedAt}"
|
|
|
|
)
|
2012-07-17 09:15:04 -07:00
|
|
|
def image_versions
|
2024-02-08 10:03:10 -08:00
|
|
|
if Rails.env.production?
|
|
|
|
# Now, instead of using the saved outfit to S3, we're using out the
|
|
|
|
# DTI 2020 API + CDN cache version. We use openneo-assets.net to get
|
|
|
|
# around a bug on Neopets petpages with openneo.net URLs.
|
|
|
|
base_url = "https://outfits.openneo-assets.net/outfits" +
|
|
|
|
"/#{CGI.escape id.to_s}" +
|
|
|
|
"/v/#{CGI.escape updated_at.to_i.to_s}"
|
|
|
|
{
|
|
|
|
large: "#{base_url}/600.png",
|
|
|
|
medium: "#{base_url}/300.png",
|
|
|
|
small: "#{base_url}/150.png",
|
|
|
|
}
|
|
|
|
else
|
|
|
|
# In development, just talk to our local Impress 2020 directly.
|
|
|
|
build_url = -> size {
|
|
|
|
IMAGE_URL_TEMPLATE.expand(
|
|
|
|
id: id, size: size, updatedAt: updated_at.to_i
|
|
|
|
).to_s
|
|
|
|
}
|
|
|
|
{
|
|
|
|
large: build_url.call("600"),
|
|
|
|
medium: build_url.call("300"),
|
|
|
|
small: build_url.call("150"),
|
|
|
|
}
|
|
|
|
end
|
Start serving outfit images via Impress 2020
As part of our project to get off S3 and dramatically reduce costs, we're gonna start serving outfit images that Impress 2020 generates, fronted by Vercel's CDN cache! This should hopefully be just as fast in practice, without requiring an S3 storage cost. (Outfits whose thumbnails are pretty much unused will be evicted from the cache, or never stored in the first place—and regenerated back into the cache on-demand if needed.)
One important note is that the image at the URL will no longer be guaranteed to auto-update to reflect the changes to the outfit, because we're including `updated_at` in the URL for caching. (It also isn't guaranteed to _not_ auto-update, though 😅) Our hope is that people aren't using it for that use case so much! If so, though, we have some ways we could build live URLs without putting too much pressure on image generation, e.g. redirects 🤔
This change does _not_ disable actual outfit generation, because I want to keep that running until we see these new URLs succeed for folks. Gonna wait a bit and see if we get bug reports on them! Then, if all goes well, we'll stop enqueueing outfit image jobs altogether, and maybe wind down some of the infrastructure accordingly.
2021-05-20 20:52:19 -07:00
|
|
|
|
|
|
|
# NOTE: Below is the previous code that uses the saved outfits!
|
|
|
|
# {}.tap do |versions|
|
|
|
|
# versions[:large] = image.url
|
|
|
|
# image.versions.each { |name, version| versions[name] = version.url }
|
|
|
|
# end
|
2010-11-11 10:43:22 -08:00
|
|
|
end
|
2021-05-24 20:05:25 -07:00
|
|
|
|
|
|
|
def as_json(more_options={})
|
2023-11-02 13:50:33 -07:00
|
|
|
serializable_hash(
|
2024-02-01 05:55:19 -08:00
|
|
|
only: [:id, :name, :pet_state_id, :starred, :created_at, :updated_at,
|
|
|
|
:alt_style_id],
|
2023-11-02 13:50:33 -07:00
|
|
|
methods: [:color_id, :species_id, :pose, :item_ids, :user]
|
|
|
|
)
|
2010-11-13 14:26:14 -08:00
|
|
|
end
|
2011-03-23 15:23:01 -07:00
|
|
|
|
2023-11-02 13:50:33 -07:00
|
|
|
def biology=(biology)
|
Oops, fix bug with saving outfits of pets loaded from Neopets.com
Okay right, the wardrobe-2020 app treats `state` as a bit of an
override thing, and `pose` is the main canonical field for how a pet
looks. We were missing a few pieces here:
1. After loading a pet, we weren't including the `pose` field in the
initial query string for the wardrobe URL, but we _were_ including
the `state` field, so the outfit would get set up with a conflicting
pet state ID vs pose.
2. When saving an outfit, we weren't taking the `state` field into
account at all. This could cause the saved outfit to not quite match
how it actually looked in-app, because the default pet state for
that species/color/pose trio could be different; and regardless, the
outfit state would come back with `appearanceId` set to `null`,
which wouldn't match the local outfit state, which would trigger an
infinite loop.
Here, we complete the round-trip of the `state` field, from pet loading
to outfit saving to the outfit data that comes back after saving!
2024-02-08 09:51:31 -08:00
|
|
|
@biology = biology.slice(:species_id, :color_id, :pose, :pet_state_id)
|
2023-11-02 13:50:33 -07:00
|
|
|
|
|
|
|
begin
|
Oops, fix bug with saving outfits of pets loaded from Neopets.com
Okay right, the wardrobe-2020 app treats `state` as a bit of an
override thing, and `pose` is the main canonical field for how a pet
looks. We were missing a few pieces here:
1. After loading a pet, we weren't including the `pose` field in the
initial query string for the wardrobe URL, but we _were_ including
the `state` field, so the outfit would get set up with a conflicting
pet state ID vs pose.
2. When saving an outfit, we weren't taking the `state` field into
account at all. This could cause the saved outfit to not quite match
how it actually looked in-app, because the default pet state for
that species/color/pose trio could be different; and regardless, the
outfit state would come back with `appearanceId` set to `null`,
which wouldn't match the local outfit state, which would trigger an
infinite loop.
Here, we complete the round-trip of the `state` field, from pet loading
to outfit saving to the outfit data that comes back after saving!
2024-02-08 09:51:31 -08:00
|
|
|
if @biology[:pet_state_id]
|
|
|
|
self.pet_state = PetState.find(@biology[:pet_state_id])
|
|
|
|
else
|
|
|
|
pet_type = PetType.where(
|
|
|
|
species_id: @biology[:species_id],
|
|
|
|
color_id: @biology[:color_id],
|
|
|
|
).first!
|
|
|
|
self.pet_state = pet_type.pet_states.with_pose(@biology[:pose]).
|
|
|
|
emotion_order.first!
|
|
|
|
end
|
2023-11-02 13:50:33 -07:00
|
|
|
rescue ActiveRecord::RecordNotFound
|
|
|
|
# If there's no such pet state (which shouldn't happen normally in-app),
|
|
|
|
# we don't set `pet_state` but we keep `@biology` for validation.
|
2010-11-10 13:59:54 -08:00
|
|
|
end
|
|
|
|
end
|
2011-03-23 15:23:01 -07:00
|
|
|
|
2023-11-02 13:50:33 -07:00
|
|
|
def item_ids
|
|
|
|
rels = item_outfit_relationships
|
|
|
|
{
|
|
|
|
worn: rels.filter { |r| r.is_worn? }.map { |r| r.item_id },
|
|
|
|
closeted: rels.filter { |r| !r.is_worn? }.map { |r| r.item_id }
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
def item_ids=(item_ids)
|
|
|
|
# Ensure there are no duplicates between the worn/closeted IDs. If an ID is
|
|
|
|
# present in both, it's kept in `worn` and removed from `closeted`.
|
|
|
|
worn_item_ids = item_ids.fetch(:worn, []).uniq
|
|
|
|
closeted_item_ids = item_ids.fetch(:closeted, []).uniq
|
|
|
|
closeted_item_ids.reject! { |id| worn_item_ids.include?(id) }
|
|
|
|
|
|
|
|
# Set the worn and closeted item outfit relationships. If there are any
|
|
|
|
# others attached to this outfit, they are implicitly deleted.
|
|
|
|
new_relationships = []
|
|
|
|
new_relationships += worn_item_ids.map do |item_id|
|
|
|
|
ItemOutfitRelationship.new(item_id: item_id, is_worn: true)
|
|
|
|
end
|
|
|
|
new_relationships += closeted_item_ids.map do |item_id|
|
|
|
|
ItemOutfitRelationship.new(item_id: item_id, is_worn: false)
|
2010-11-10 13:59:54 -08:00
|
|
|
end
|
2023-11-02 13:50:33 -07:00
|
|
|
self.item_outfit_relationships = new_relationships
|
2010-11-10 13:59:54 -08:00
|
|
|
end
|
2011-03-23 15:23:01 -07:00
|
|
|
|
2024-07-01 16:07:25 -07:00
|
|
|
def item_appearances(...)
|
2024-07-01 16:54:39 -07:00
|
|
|
Item.appearances_for(worn_items, pet_type, ...).values
|
2024-07-01 16:07:25 -07:00
|
|
|
end
|
|
|
|
|
|
|
|
def visible_layers
|
2024-07-01 16:54:39 -07:00
|
|
|
item_appearances = item_appearances(swf_asset_includes: [:zone])
|
|
|
|
|
|
|
|
pet_layers = pet_state.swf_assets.includes(:zone).to_a
|
|
|
|
item_layers = item_appearances.map(&:swf_assets).flatten
|
|
|
|
|
|
|
|
pet_restricted_zone_ids = pet_layers.map(&:restricted_zone_ids).
|
|
|
|
flatten.to_set
|
|
|
|
item_restricted_zone_ids = item_appearances.
|
|
|
|
map(&:restricted_zone_ids).flatten.to_set
|
|
|
|
|
|
|
|
# When an item restricts a zone, it hides pet layers of the same zone.
|
|
|
|
# We use this to e.g. make a hat hide a hair ruff.
|
|
|
|
#
|
|
|
|
# NOTE: Items' restricted layers also affect what items you can wear at
|
|
|
|
# the same time. We don't enforce anything about that here, and
|
|
|
|
# instead assume that the input by this point is valid!
|
|
|
|
pet_layers.reject! { |sa| item_restricted_zone_ids.include?(sa.zone_id) }
|
|
|
|
|
|
|
|
# When a pet appearance restricts a zone, or when the pet is Unconverted,
|
|
|
|
# it makes body-specific items incompatible. We use this to disallow UCs
|
|
|
|
# from wearing certain body-specific Biology Effects, Statics, etc, while
|
|
|
|
# still allowing non-body-specific items in those zones! (I think this
|
|
|
|
# happens for some Invisible pet stuff, too?)
|
|
|
|
#
|
|
|
|
# TODO: We shouldn't be *hiding* these zones, like we do with items; we
|
|
|
|
# should be doing this way earlier, to prevent the item from even
|
|
|
|
# showing up even in search results!
|
|
|
|
#
|
|
|
|
# NOTE: This can result in both pet layers and items occupying the same
|
|
|
|
# zone, like Static, so long as the item isn't body-specific! That's
|
|
|
|
# correct, and the item layer should be on top! (Here, we implement
|
|
|
|
# it by placing item layers second in the list, and rely on JS sort
|
|
|
|
# stability, and *then* rely on the UI to respect that ordering when
|
|
|
|
# rendering them by depth. Not great! 😅)
|
|
|
|
#
|
|
|
|
# NOTE: We used to also include the pet appearance's *occupied* zones in
|
|
|
|
# this condition, not just the restricted zones, as a sensible
|
|
|
|
# defensive default, even though we weren't aware of any relevant
|
|
|
|
# items. But now we know that actually the "Bruce Brucey B Mouth"
|
|
|
|
# occupies the real Mouth zone, and still should be visible and
|
|
|
|
# above pet layers! So, we now only check *restricted* zones.
|
|
|
|
#
|
|
|
|
# NOTE: UCs used to implement their restrictions by listing specific
|
|
|
|
# zones, but it seems that the logic has changed to just be about
|
|
|
|
# UC-ness and body-specific-ness, and not necessarily involve the
|
|
|
|
# set of restricted zones at all. (This matters because e.g. UCs
|
|
|
|
# shouldn't show _any_ part of the Rainy Day Umbrella, but most UCs
|
|
|
|
# don't restrict Right-Hand Item (Zone 49).) Still, I'm keeping the
|
|
|
|
# zone restriction case running too, because I don't think it
|
|
|
|
# _hurts_ anything, and I'm not confident enough in this conclusion.
|
|
|
|
#
|
|
|
|
# TODO: Do Invisibles follow this new rule like UCs, too? Or do they still
|
|
|
|
# use zone restrictions?
|
|
|
|
if pet_state.pose === "UNCONVERTED"
|
|
|
|
item_layers.reject! { |sa| sa.body_specific? }
|
|
|
|
else
|
|
|
|
item_layers.reject! { |sa| sa.body_specific? &&
|
|
|
|
pet_restricted_zone_ids.include?(sa.zone_id) }
|
|
|
|
end
|
|
|
|
|
|
|
|
# A pet appearance can also restrict its own zones. The Wraith Uni is an
|
|
|
|
# interesting example: it has a horn, but its zone restrictions hide it!
|
|
|
|
pet_layers.reject! { |sa| pet_restricted_zone_ids.include?(sa.zone_id) }
|
2024-07-01 16:07:25 -07:00
|
|
|
|
|
|
|
(pet_layers + item_layers).sort_by(&:depth)
|
|
|
|
end
|
|
|
|
|
2023-11-02 13:50:33 -07:00
|
|
|
def ensure_unique_name
|
|
|
|
# If no name was provided, start with "Untitled outfit".
|
|
|
|
self.name = "Untitled outfit" if name.blank?
|
|
|
|
|
|
|
|
# Strip whitespace from the name.
|
|
|
|
self.name.strip!
|
|
|
|
|
|
|
|
# Get the base name of the provided name, without any "(1)" suffixes.
|
2024-06-28 01:32:15 -07:00
|
|
|
base_name = name.sub(/\s*\([0-9]+\)\z/, '')
|
2023-11-02 13:50:33 -07:00
|
|
|
|
|
|
|
# Find the user's other outfits that start with the same base name, and get
|
|
|
|
# *their* names, with whitespace stripped.
|
|
|
|
existing_outfits = self.user.outfits.
|
|
|
|
where("name LIKE ?", Outfit.sanitize_sql_like(base_name) + "%")
|
|
|
|
existing_outfits = existing_outfits.where("id != ?", id) unless id.nil?
|
|
|
|
existing_names = existing_outfits.map(&:name).map(&:strip)
|
|
|
|
|
|
|
|
# Try the provided name first, but if it's taken, add a "(1)" suffix and
|
|
|
|
# keep incrementing it until it's not.
|
|
|
|
i = 1
|
|
|
|
while existing_names.include?(name)
|
|
|
|
self.name = "#{base_name} (#{i})"
|
|
|
|
i += 1
|
2011-02-10 14:50:47 -08:00
|
|
|
end
|
|
|
|
end
|
2010-11-10 13:59:54 -08:00
|
|
|
end
|