diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb index ff723610..ad7ec488 100644 --- a/app/controllers/items_controller.rb +++ b/app/controllers/items_controller.rb @@ -187,7 +187,7 @@ class ItemsController < ApplicationController appearance_params[:color_id], appearance_params[:species_id]) end - target.appearances_for(@items.map(&:id), swf_asset_includes: [:zone]). + target.appearances_for(@items, swf_asset_includes: [:zone]). tap do |appearances| # Preload the manifests for these SWF assets concurrently, rather than # loading them in sequence when we generate the JSON. @@ -223,7 +223,7 @@ class ItemsController < ApplicationController def validate_preview if @selected_preview_pet_type.new_record? :pet_type_does_not_exist - elsif @preview_outfit.item_appearances.values.any?(&:empty?) + elsif @preview_outfit.item_appearances.any?(&:empty?) :no_item_data end end diff --git a/app/models/alt_style.rb b/app/models/alt_style.rb index 53d958b6..703b1501 100644 --- a/app/models/alt_style.rb +++ b/app/models/alt_style.rb @@ -49,9 +49,9 @@ class AltStyle < ApplicationRecord swf_asset.image_url end - # Given a list of item IDs, return how they look on this alt style. - def appearances_for(item_ids, ...) - Item.appearances_for(item_ids, self, ...) + # Given a list of items, return how they look on this alt style. + def appearances_for(items, ...) + Item.appearances_for(items, self, ...) end def biology=(biology) diff --git a/app/models/item.rb b/app/models/item.rb index 3a3c6b37..1371e345 100644 --- a/app/models/item.rb +++ b/app/models/item.rb @@ -555,17 +555,23 @@ class Item < ApplicationRecord # 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 + Appearance = Struct.new(:item, :body, :swf_assets) do include ActiveModel::Serializers::JSON delegate :present?, :empty?, to: :swf_assets + def attributes - {body: body, swf_assets: swf_assets} + {item:, body:, swf_assets:} + end + + def restricted_zone_ids + return [] if empty? + ([item] + swf_assets).map(&:restricted_zone_ids).flatten.uniq.sort end end Appearance::Body = Struct.new(:id, :species) do include ActiveModel::Serializers::JSON def attributes - {id: id, species: species} + {id:, species:} end end @@ -582,7 +588,7 @@ class Item < ApplicationRecord # 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)] + return [Appearance.new(self, body, swf_assets_for_all_bodies)] end # Otherwise, create an appearance for each real (nonzero) body ID. We don't @@ -592,22 +598,22 @@ class Item < ApplicationRecord 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) + Appearance.new(self, body, swf_assets_for_body) end end def appearance_for(target, ...) - Item.appearances_for([id], target, ...)[id] + Item.appearances_for([self], target, ...)[id] 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: []) + # Given a list of items, return how they look on the given target (either a + # pet type or an alt style). + def self.appearances_for(items, 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(parent_type: "Item", parent_id: items.map(&:id)). where(swf_asset: {body_id: [target.body_id, 0]}) pet_type_body = Appearance::Body.new(target.body_id, target.species) @@ -618,13 +624,13 @@ class Item < ApplicationRecord 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, []) + items.to_h do |item| + 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)] + [item.id, Appearance.new(item, body, assets)] end end diff --git a/app/models/outfit.rb b/app/models/outfit.rb index 5a293d83..612964bd 100644 --- a/app/models/outfit.rb +++ b/app/models/outfit.rb @@ -160,13 +160,73 @@ class Outfit < ApplicationRecord end def item_appearances(...) - Item.appearances_for(worn_item_ids, pet_type, ...) + Item.appearances_for(worn_items, pet_type, ...).values end def visible_layers - pet_layers = pet_state.swf_assets.includes(:zone) - item_layers = item_appearances(swf_asset_includes: [:zone]).values. - map(&:swf_assets).flatten + 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) } (pet_layers + item_layers).sort_by(&:depth) end diff --git a/app/models/pet_type.rb b/app/models/pet_type.rb index b34d2910..412ddc88 100644 --- a/app/models/pet_type.rb +++ b/app/models/pet_type.rb @@ -124,9 +124,9 @@ class PetType < ApplicationRecord }.first end - # Given a list of item IDs, return how they look on this pet type. - def appearances_for(item_ids, ...) - Item.appearances_for(item_ids, self, ...) + # Given a list of items, return how they look on this pet type. + def appearances_for(item, ...) + Item.appearances_for(item, self, ...) end def self.all_by_ids_or_children(ids, pet_states)