diff --git a/app/models/item.rb b/app/models/item.rb index fef2ece7..e1b1c5db 100644 --- a/app/models/item.rb +++ b/app/models/item.rb @@ -549,6 +549,22 @@ class Item < ApplicationRecord return [] if empty? ([item] + swf_assets).map(&:restricted_zone_ids).flatten.uniq.sort end + + # Check if this appearance is compatible with another appearance. + # Two appearances are incompatible if: + # 1. They occupy the same zone (can't wear two items in same slot) + # 2. One restricts a zone the other occupies (e.g., hat restricts hair zone) + def compatible_with?(other) + occupied = occupied_zone_ids + other_occupied = other.occupied_zone_ids + restricted = restricted_zone_ids + other_restricted = other.restricted_zone_ids + + # Check for zone conflicts + (occupied & other_occupied).empty? && + (restricted & other_occupied).empty? && + (other_restricted & occupied).empty? + end end Appearance::Body = Struct.new(:id, :species) do include ActiveModel::Serializers::JSON diff --git a/app/models/outfit.rb b/app/models/outfit.rb index 8c4db97e..21138789 100644 --- a/app/models/outfit.rb +++ b/app/models/outfit.rb @@ -287,8 +287,8 @@ class Outfit < ApplicationRecord # associations. def dup super.tap do |outfit| - outfit.worn_item_ids = self.worn_item_ids - outfit.closeted_item_ids = self.closeted_item_ids + outfit.worn_items = self.worn_items + outfit.closeted_items = self.closeted_items end end @@ -298,7 +298,38 @@ class Outfit < ApplicationRecord end # Create a copy of this outfit, additionally wearing the given item. + # Automatically moves any incompatible worn items to the closet. def with_item(item) - dup.tap { |o| o.worn_items << item unless o.worn_items.include?(item) } + dup.tap do |o| + # Skip if item is nil, already worn, or outfit has no pet_state + next if item.nil? || o.worn_item_ids.include?(item.id) || o.pet_state.nil? + + # Load appearances for the new item and all currently worn items + all_items = o.worn_items + [item] + appearances = Item.appearances_for(all_items, o.pet_type, + swf_asset_includes: [:zone]) + + new_item_appearance = appearances[item.id] + + # If the new item has no appearance (doesn't fit this pet), skip it + next if new_item_appearance.empty? + + # Find items that conflict with the new item + conflicting_items = o.worn_items.select do |worn_item| + worn_appearance = appearances[worn_item.id] + # Empty appearances are always compatible + !worn_appearance.empty? && + !new_item_appearance.compatible_with?(worn_appearance) + end + + # Move conflicting items to closet + conflicting_items.each do |conflicting_item| + o.worn_items.delete(conflicting_item) + o.closeted_items << conflicting_item unless o.closeted_item_ids.include?(conflicting_item.id) + end + + # Add the new item + o.worn_items << item + end end end diff --git a/spec/models/outfit_spec.rb b/spec/models/outfit_spec.rb new file mode 100644 index 00000000..b1a4758f --- /dev/null +++ b/spec/models/outfit_spec.rb @@ -0,0 +1,288 @@ +require_relative '../rails_helper' + +RSpec.describe Outfit do + fixtures :colors, :species, :zones + + let(:blue) { colors(:blue) } + let(:acara) { species(:acara) } + + before do + PetType.destroy_all + @pet_type = PetType.create!(color: blue, species: acara, body_id: 1) + @pet_state = create_pet_state(@pet_type, "HAPPY_MASC") + @outfit = Outfit.new(pet_state: @pet_state) + end + + def create_pet_state(pet_type, pose) + # Create a basic biology asset so pet state saves correctly + swf_asset = SwfAsset.create!( + type: "biology", + remote_id: (SwfAsset.maximum(:remote_id) || 0) + 1, + url: "https://images.neopets.example/biology.swf", + zone: zones(:body), + zones_restrict: "", + body_id: pet_type.body_id + ) + PetState.create!( + pet_type: pet_type, + pose: pose, + swf_assets: [swf_asset], + swf_asset_ids: [swf_asset.id] + ) + end + + def create_item(name, zone, body_id: 1, zones_restrict: "") + item = Item.create!( + name: name, + description: "Test item", + thumbnail_url: "https://images.neopets.example/item.png", + zones_restrict: zones_restrict, + rarity: "Common", + price: 100 + ) + swf_asset = SwfAsset.create!( + type: "object", + remote_id: (SwfAsset.maximum(:remote_id) || 0) + 1, + url: "https://images.neopets.example/#{name}.swf", + zone: zone, + zones_restrict: zones_restrict, + body_id: body_id + ) + item.swf_assets << swf_asset + item + end + + describe "Item::Appearance#compatible_with?" do + it "returns true for items in different zones with no restrictions" do + hat = create_item("Hat", zones(:hat)) + shirt = create_item("Shirt", zones(:shirtdress)) + + appearances = Item.appearances_for([hat, shirt], @pet_type) + hat_appearance = appearances[hat.id] + shirt_appearance = appearances[shirt.id] + + expect(hat_appearance.compatible_with?(shirt_appearance)).to be true + expect(shirt_appearance.compatible_with?(hat_appearance)).to be true + end + + it "returns false for items in the same zone" do + hat1 = create_item("Hat 1", zones(:hat)) + hat2 = create_item("Hat 2", zones(:hat)) + + appearances = Item.appearances_for([hat1, hat2], @pet_type) + hat1_appearance = appearances[hat1.id] + hat2_appearance = appearances[hat2.id] + + expect(hat1_appearance.compatible_with?(hat2_appearance)).to be false + expect(hat2_appearance.compatible_with?(hat1_appearance)).to be false + end + + it "returns false when one item restricts a zone the other occupies" do + # Create a hat that restricts the ruff zone (zone 29) + # The zones_restrict format is a 52-character bitstring where bit N corresponds to zone N+1 + # Zones are 1-indexed, so zone 29 needs the bit at position 28 (0-indexed from right) + # Build string from right to left: 28 zeros, then "1", then 23 zeros + zones_restrict = ("0" * 23 + "1" + "0" * 28).reverse.chars.reverse.join + + # Simpler approach: create a 52-char string with bit 28 set to "1" + zones_restrict_array = Array.new(52, "0") + zones_restrict_array[28] = "1" # Set bit for zone 29 + zones_restrict = zones_restrict_array.join + + restricting_hat = create_item("Restricting Hat", zones(:hat), zones_restrict: zones_restrict) + + # Create an item in the ruff zone + ruff_item = create_item("Ruff Item", zones(:ruff)) + + appearances = Item.appearances_for([restricting_hat, ruff_item], @pet_type) + hat_appearance = appearances[restricting_hat.id] + ruff_appearance = appearances[ruff_item.id] + + expect(hat_appearance.compatible_with?(ruff_appearance)).to be false + expect(ruff_appearance.compatible_with?(hat_appearance)).to be false + end + + it "returns true for empty appearances" do + # Create items that don't fit the current pet (wrong body_id) + hat = create_item("Hat", zones(:hat), body_id: 999) + shirt = create_item("Shirt", zones(:shirtdress), body_id: 999) + + appearances = Item.appearances_for([hat, shirt], @pet_type) + hat_appearance = appearances[hat.id] + shirt_appearance = appearances[shirt.id] + + # Both should be empty (no swf_assets for this pet) + expect(hat_appearance).to be_empty + expect(shirt_appearance).to be_empty + + # Empty appearances should be compatible + expect(hat_appearance.compatible_with?(shirt_appearance)).to be true + end + end + + describe "#without_item" do + it "returns a new outfit without the given item" do + hat = create_item("Hat", zones(:hat)) + outfit_with_hat = @outfit.with_item(hat) + + new_outfit = outfit_with_hat.without_item(hat) + + expect(new_outfit.worn_items).not_to include(hat) + expect(outfit_with_hat.worn_items).to include(hat) # Original unchanged + end + + it "returns a new outfit instance (immutable)" do + hat = create_item("Hat", zones(:hat)) + outfit_with_hat = @outfit.with_item(hat) + + new_outfit = outfit_with_hat.without_item(hat) + + expect(new_outfit).not_to eq(outfit_with_hat) + expect(new_outfit.object_id).not_to eq(outfit_with_hat.object_id) + end + + it "does nothing if the item is not worn" do + hat = create_item("Hat", zones(:hat)) + + new_outfit = @outfit.without_item(hat) + + expect(new_outfit.worn_items).to be_empty + end + end + + describe "#with_item" do + it "adds an item when there are no conflicts" do + hat = create_item("Hat", zones(:hat)) + + new_outfit = @outfit.with_item(hat) + + expect(new_outfit.worn_items).to include(hat) + end + + it "returns a new outfit instance (immutable)" do + hat = create_item("Hat", zones(:hat)) + + new_outfit = @outfit.with_item(hat) + + expect(new_outfit).not_to eq(@outfit) + expect(new_outfit.object_id).not_to eq(@outfit.object_id) + expect(@outfit.worn_items).to be_empty # Original unchanged + end + + it "is idempotent (adding same item twice has no effect)" do + hat = create_item("Hat", zones(:hat)) + + outfit1 = @outfit.with_item(hat) + outfit2 = outfit1.with_item(hat) + + expect(outfit1.worn_items.size).to eq(1) + expect(outfit2.worn_items.size).to eq(1) + expect(outfit2.worn_items).to include(hat) + end + + it "does not add items that don't fit this pet" do + # Create item with wrong body_id + hat = create_item("Hat", zones(:hat), body_id: 999) + + new_outfit = @outfit.with_item(hat) + + expect(new_outfit.worn_items).to be_empty + end + + context "with conflicting items" do + it "moves conflicting item to closet when items occupy the same zone" do + hat1 = create_item("Hat 1", zones(:hat)) + hat2 = create_item("Hat 2", zones(:hat)) + + outfit_with_hat1 = @outfit.with_item(hat1) + outfit_with_hat2 = outfit_with_hat1.with_item(hat2) + + expect(outfit_with_hat2.worn_items).to include(hat2) + expect(outfit_with_hat2.worn_items).not_to include(hat1) + expect(outfit_with_hat2.closeted_items).to include(hat1) + end + + it "moves conflicting item to closet when new item restricts zone" do + # Create item in ruff zone + ruff_item = create_item("Ruff Item", zones(:ruff)) + + # Create hat that restricts ruff zone (zone 29) + # zones_restrict is 0-indexed, so zone 29 needs bit 28 to be "1" + zones_restrict_array = Array.new(52, "0") + zones_restrict_array[28] = "1" + zones_restrict = zones_restrict_array.join + restricting_hat = create_item("Restricting Hat", zones(:hat), zones_restrict: zones_restrict) + + # First wear ruff item, then wear restricting hat + outfit_with_ruff = @outfit.with_item(ruff_item) + outfit_with_hat = outfit_with_ruff.with_item(restricting_hat) + + expect(outfit_with_hat.worn_items).to include(restricting_hat) + expect(outfit_with_hat.worn_items).not_to include(ruff_item) + expect(outfit_with_hat.closeted_items).to include(ruff_item) + end + + it "keeps compatible items when adding new item" do + hat = create_item("Hat", zones(:hat)) + shirt = create_item("Shirt", zones(:shirtdress)) + pants = create_item("Pants", zones(:trousers)) + + outfit1 = @outfit.with_item(hat).with_item(shirt) + outfit2 = outfit1.with_item(pants) + + expect(outfit2.worn_items).to include(hat, shirt, pants) + expect(outfit2.closeted_items).to be_empty + end + + it "can move multiple conflicting items to closet" do + hat1 = create_item("Hat 1", zones(:hat)) + hat2 = create_item("Hat 2", zones(:hat)) + hat3 = create_item("Hat 3", zones(:hat)) + + # Wear hat1 and hat2 by manually building the outfit + # (normally you can't, but we're testing the conflict resolution) + outfit = @outfit.dup + outfit.worn_items << hat1 + outfit.worn_items << hat2 + + # Now add hat3, which should move both hat1 and hat2 to closet + outfit_with_hat3 = outfit.with_item(hat3) + + expect(outfit_with_hat3.worn_items).to contain_exactly(hat3) + expect(outfit_with_hat3.closeted_items).to contain_exactly(hat1, hat2) + end + + it "does not duplicate items in closet if already closeted" do + hat1 = create_item("Hat 1", zones(:hat)) + hat2 = create_item("Hat 2", zones(:hat)) + + # Wear hat1 + outfit1 = @outfit.with_item(hat1) + + # Add hat2 (moves hat1 to closet) + outfit2 = outfit1.with_item(hat2) + + # Add hat2 again (should be idempotent, not duplicate hat1 in closet) + outfit3 = outfit2.with_item(hat2) + + expect(outfit3.closeted_items.size).to eq(1) + expect(outfit3.closeted_items).to include(hat1) + end + end + + context "edge cases" do + it "handles nil item gracefully" do + expect { @outfit.with_item(nil) }.not_to raise_error + end + + it "works with outfit that has no pet_state" do + # This shouldn't happen in practice, but let's be defensive + outfit_no_pet = Outfit.new + hat = create_item("Hat", zones(:hat)) + + # Should not crash, but also won't add the item + expect { outfit_no_pet.with_item(hat) }.not_to raise_error + end + end + end +end