[WV2] Wearing item unwears incompatible items

This commit is contained in:
Emi Matchu 2025-11-03 07:49:16 +00:00
parent e72a0ec72f
commit ab46d90d6a
3 changed files with 338 additions and 3 deletions

View file

@ -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

View file

@ -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

288
spec/models/outfit_spec.rb Normal file
View file

@ -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