1
0
Fork 0
forked from OpenNeo/impress
impress/app/models/outfit.rb
Emi Matchu dc44b4dbb3 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

176 lines
5.2 KiB
Ruby

class Outfit < ApplicationRecord
has_many :item_outfit_relationships, :dependent => :destroy
has_many :worn_item_outfit_relationships, -> { where(is_worn: true) },
class_name: 'ItemOutfitRelationship'
has_many :worn_items, through: :worn_item_outfit_relationships, source: :item
belongs_to :alt_style, optional: true
belongs_to :pet_state, optional: true # We validate presence below!
belongs_to :user, optional: true
validates :name, :presence => {:if => :user_id}, :uniqueness => {:scope => :user_id, :if => :user_id}
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
}
before_validation :ensure_unique_name, if: :user_id?
attr_reader :biology
delegate :color, to: :pet_state
scope :wardrobe_order, -> { order('starred DESC', :name) }
class OutfitImage
def initialize(image_versions)
@image_versions = image_versions
end
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)
end
def image_versions
# 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",
}
# 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
end
def as_json(more_options={})
serializable_hash(
only: [:id, :name, :pet_state_id, :starred, :created_at, :updated_at,
:alt_style_id],
methods: [:color_id, :species_id, :pose, :item_ids, :user]
)
end
def color_id
pet_state.pet_type.color_id
end
def species_id
pet_state.pet_type.species_id
end
def pose
pet_state.pose
end
def biology=(biology)
@biology = biology.slice(:species_id, :color_id, :pose, :pet_state_id)
begin
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
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.
end
end
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)
end
self.item_outfit_relationships = new_relationships
end
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.
base_name = name.sub(/\s*\([0-9]+\)$/, '')
# 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
end
end
end