Compare commits

...

3 commits

10 changed files with 892 additions and 30 deletions

View file

@ -61,6 +61,9 @@ gem "async", "~> 2.17", require: false
gem "async-http", "~> 0.89.0", require: false gem "async-http", "~> 0.89.0", require: false
gem "thread-local", "~> 1.1", require: false gem "thread-local", "~> 1.1", require: false
# For image processing (outfit PNG rendering).
gem "ruby-vips", "~> 2.2"
# For debugging. # For debugging.
group :development do group :development do
gem 'debug', '~> 1.9.2' gem 'debug', '~> 1.9.2'

View file

@ -245,7 +245,6 @@ GEM
memory_profiler (1.1.0) memory_profiler (1.1.0)
metrics (0.15.0) metrics (0.15.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (6.0.1) minitest (6.0.1)
prism (~> 1.5) prism (~> 1.5)
msgpack (1.8.0) msgpack (1.8.0)
@ -263,9 +262,6 @@ GEM
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.18.10)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-gnu) nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin) nokogiri (1.18.10-arm64-darwin)
@ -431,6 +427,9 @@ GEM
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.4) prism (~> 1.4)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
samovar (2.4.1) samovar (2.4.1)
console (~> 1.0) console (~> 1.0)
mapping (~> 1.0) mapping (~> 1.0)
@ -541,7 +540,6 @@ GEM
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
arm64-darwin arm64-darwin
ruby
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES
@ -571,6 +569,7 @@ DEPENDENCIES
rails-i18n (~> 8.0, >= 8.0.1) rails-i18n (~> 8.0, >= 8.0.1)
rdiscount (~> 2.2, >= 2.2.7.1) rdiscount (~> 2.2, >= 2.2.7.1)
rspec-rails (~> 7.0) rspec-rails (~> 7.0)
ruby-vips (~> 2.2)
sanitize (~> 6.0, >= 6.0.2) sanitize (~> 6.0, >= 6.0.2)
sass-rails (~> 6.0) sass-rails (~> 6.0)
sentry-rails (~> 5.12) sentry-rails (~> 5.12)

View file

@ -13,7 +13,26 @@ class OutfitsController < ApplicationController
end end
def edit def edit
render "outfits/edit", layout: false respond_to do |format|
format.html { render "outfits/edit", layout: false }
format.png do
@outfit = build_outfit_from_wardrobe_params
if @outfit.valid?
renderer = OutfitImageRenderer.new(@outfit)
png_data = renderer.render
if png_data
send_data png_data, type: "image/png", disposition: "inline",
filename: "outfit.png"
expires_in 1.day, public: true
else
head :not_found
end
else
head :bad_request
end
end
end
end end
def index def index
@ -117,6 +136,40 @@ class OutfitsController < ApplicationController
biology: [:species_id, :color_id, :pose, :pet_state_id]) biology: [:species_id, :color_id, :pose, :pet_state_id])
end end
def build_outfit_from_wardrobe_params
# Load items first
worn_item_ids = params[:objects] ? Array(params[:objects]).map(&:to_i) : []
closeted_item_ids = params[:closet] ? Array(params[:closet]).map(&:to_i) : []
worn_items = Item.where(id: worn_item_ids)
closeted_items = Item.where(id: closeted_item_ids)
# Build outfit with biology and items
outfit = Outfit.new(
worn_items: worn_items,
closeted_items: closeted_items,
)
# Set biology from species, color, and pose params
if params[:species] && params[:color] && params[:pose]
outfit.biology = {
species_id: params[:species],
color_id: params[:color],
pose: params[:pose]
}
elsif params[:state]
# Alternative: use pet_state_id directly
outfit.biology = { pet_state_id: params[:state] }
end
# Set alt style if provided
if params[:style]
outfit.alt_style_id = params[:style].to_i
end
outfit
end
def find_authorized_outfit def find_authorized_outfit
raise ActiveRecord::RecordNotFound unless user_signed_in? raise ActiveRecord::RecordNotFound unless user_signed_in?
@outfit = current_user.outfits.find(params[:id]) @outfit = current_user.outfits.find(params[:id])

View file

@ -170,52 +170,67 @@ class Outfit < ApplicationRecord
end end
def visible_layers def visible_layers
# TODO: This method doesn't currently handle alt styles! If the outfit has # Step 1: Choose biology layers - use alt style if present, otherwise pet state
# an alt_style, we should use its layers instead of pet_state layers, and if alt_style
# filter items to only those with body_id=0. This isn't needed yet because biology_layers = alt_style.swf_assets.includes(:zone).to_a
# this method is only used on item pages, which don't support alt styles. body = alt_style
# See useOutfitAppearance.js for the complete logic including alt styles. using_alt_style = true
item_appearances = item_appearances(swf_asset_includes: [:zone]) else
biology_layers = pet_state.swf_assets.includes(:zone).to_a
body = pet_type
using_alt_style = false
end
pet_layers = pet_state.swf_assets.includes(:zone).to_a # Step 2: Load item appearances for the appropriate body
item_appearances = Item.appearances_for(
worn_items,
body,
swf_asset_includes: [:zone]
).values
item_layers = item_appearances.map(&:swf_assets).flatten item_layers = item_appearances.map(&:swf_assets).flatten
pet_restricted_zone_ids = pet_layers.map(&:restricted_zone_ids). # For alt styles, only body_id=0 items are compatible
if using_alt_style
item_layers.reject! { |sa| sa.body_id != 0 }
end
# Step 3: Apply restriction rules
biology_restricted_zone_ids = biology_layers.map(&:restricted_zone_ids).
flatten.to_set flatten.to_set
item_restricted_zone_ids = item_appearances. item_restricted_zone_ids = item_appearances.
map(&:restricted_zone_ids).flatten.to_set map(&:restricted_zone_ids).flatten.to_set
# When an item restricts a zone, it hides pet layers of the same zone. # Rule 3a: When an item restricts a zone, it hides biology layers of the same zone.
# We use this to e.g. make a hat hide a hair ruff. # 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 # NOTE: Items' restricted layers also affect what items you can wear at
# the same time. We don't enforce anything about that here, and # the same time. We don't enforce anything about that here, and
# instead assume that the input by this point is valid! # instead assume that the input by this point is valid!
pet_layers.reject! { |sa| item_restricted_zone_ids.include?(sa.zone_id) } biology_layers.reject! { |sa| item_restricted_zone_ids.include?(sa.zone_id) }
# When a pet appearance restricts a zone, or when the pet is Unconverted, # Rule 3b: When a biology appearance restricts a zone, or when the pet is
# it makes body-specific items incompatible. We use this to disallow UCs # Unconverted, it makes body-specific items incompatible. We use this to
# from wearing certain body-specific Biology Effects, Statics, etc, while # disallow UCs from wearing certain body-specific Biology Effects, Statics,
# still allowing non-body-specific items in those zones! (I think this # etc, while still allowing non-body-specific items in those zones! (I think
# happens for some Invisible pet stuff, too?) # this happens for some Invisible pet stuff, too?)
# #
# TODO: We shouldn't be *hiding* these zones, like we do with items; we # 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 # should be doing this way earlier, to prevent the item from even
# showing up even in search results! # showing up even in search results!
# #
# NOTE: This can result in both pet layers and items occupying the same # NOTE: This can result in both biology layers and items occupying the same
# zone, like Static, so long as the item isn't body-specific! That's # 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 # 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 # 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 # stability, and *then* rely on the UI to respect that ordering when
# rendering them by depth. Not great! 😅) # rendering them by depth. Not great! 😅)
# #
# NOTE: We used to also include the pet appearance's *occupied* zones in # NOTE: We used to also include the biology appearance's *occupied* zones in
# this condition, not just the restricted zones, as a sensible # this condition, not just the restricted zones, as a sensible
# defensive default, even though we weren't aware of any relevant # defensive default, even though we weren't aware of any relevant
# items. But now we know that actually the "Bruce Brucey B Mouth" # items. But now we know that actually the "Bruce Brucey B Mouth"
# occupies the real Mouth zone, and still should be visible and # occupies the real Mouth zone, and still should be visible and
# above pet layers! So, we now only check *restricted* zones. # above biology layers! So, we now only check *restricted* zones.
# #
# NOTE: UCs used to implement their restrictions by listing specific # NOTE: UCs used to implement their restrictions by listing specific
# zones, but it seems that the logic has changed to just be about # zones, but it seems that the logic has changed to just be about
@ -232,18 +247,20 @@ class Outfit < ApplicationRecord
item_layers.reject! { |sa| sa.body_specific? } item_layers.reject! { |sa| sa.body_specific? }
else else
item_layers.reject! { |sa| sa.body_specific? && item_layers.reject! { |sa| sa.body_specific? &&
pet_restricted_zone_ids.include?(sa.zone_id) } biology_restricted_zone_ids.include?(sa.zone_id) }
end end
# A pet appearance can also restrict its own zones. The Wraith Uni is an # Rule 3c: A biology appearance can also restrict its own zones. The Wraith
# interesting example: it has a horn, but its zone restrictions hide it! # Uni is an interesting example: it has a horn, but its zone restrictions
pet_layers.reject! { |sa| pet_restricted_zone_ids.include?(sa.zone_id) } # hide it!
biology_layers.reject! { |sa| biology_restricted_zone_ids.include?(sa.zone_id) }
(pet_layers + item_layers).sort_by(&:depth) # Step 4: Sort by depth and return
(biology_layers + item_layers).sort_by(&:depth)
end end
def wardrobe_params def wardrobe_params
{ params = {
name: name, name: name,
color: color_id, color: color_id,
species: species_id, species: species_id,
@ -252,6 +269,8 @@ class Outfit < ApplicationRecord
objects: worn_item_ids, objects: worn_item_ids,
closet: closeted_item_ids, closet: closeted_item_ids,
} }
params[:style] = alt_style_id if alt_style_id.present?
params
end end
def ensure_unique_name def ensure_unique_name

View file

@ -191,6 +191,7 @@
name: name:
- libmysqlclient-dev - libmysqlclient-dev
- libyaml-dev - libyaml-dev
- libvips-dev
- name: Create the app folder - name: Create the app folder
file: file:

View file

@ -0,0 +1,75 @@
require "vips"
class OutfitImageRenderer
CANVAS_SIZE = 600
def initialize(outfit)
@outfit = outfit
end
def render
layers = @outfit.visible_layers
# Filter out layers without image URLs
layers_with_images = layers.select(&:image_url?)
return nil if layers_with_images.empty?
# Fetch all layer images in parallel
image_data_by_layer = fetch_layer_images(layers_with_images)
# Create transparent canvas in sRGB colorspace
canvas = Vips::Image.black(CANVAS_SIZE, CANVAS_SIZE, bands: 4)
canvas = canvas.new_from_image([0, 0, 0, 0])
canvas = canvas.copy(interpretation: :srgb)
# Composite each layer onto the canvas
layers_with_images.each do |layer|
image_data = image_data_by_layer[layer]
next unless image_data
begin
layer_image = Vips::Image.new_from_buffer(image_data, "")
# Center the layer on the canvas
x_offset = (CANVAS_SIZE - layer_image.width) / 2
y_offset = (CANVAS_SIZE - layer_image.height) / 2
# Composite this layer onto the canvas
canvas = canvas.composite([layer_image], :over, x: x_offset, y: y_offset)
rescue Vips::Error => e
# Log and skip layers that fail to load/composite
Rails.logger.warn "Failed to composite layer #{layer.id} (#{layer.image_url}): #{e.message}"
next
end
end
# Return PNG data
canvas.write_to_buffer(".png")
end
private
def fetch_layer_images(layers)
image_data_by_layer = {}
DTIRequests.load_many(max_at_once: 10) do |semaphore|
layers.each do |layer|
semaphore.async do
begin
response = DTIRequests.get(layer.image_url)
if response.success?
image_data_by_layer[layer] = response.read
else
Rails.logger.warn "Failed to fetch image for layer #{layer.id} (#{layer.image_url}): HTTP #{response.status}"
end
rescue => e
Rails.logger.warn "Error fetching image for layer #{layer.id} (#{layer.image_url}): #{e.message}"
end
end
end
image_data_by_layer
end
end
end

View file

@ -0,0 +1,166 @@
require 'webmock/rspec'
require_relative '../rails_helper'
RSpec.describe OutfitImageRenderer do
fixtures :zones, :colors, :species
# Helper to create a simple PNG image (1x1 pixel) with a specific color
def create_test_png(red, green, blue, alpha = 255)
require 'vips'
image = Vips::Image.black(1, 1, bands: 4)
image = image.new_from_image([red, green, blue, alpha])
image.write_to_buffer('.png')
end
# Helper to create a pet state with specific swf_assets
def build_pet_state(pet_type, pose: "HAPPY_FEM", swf_assets: [])
pet_state = PetState.create!(
pet_type: pet_type,
pose: pose,
swf_asset_ids: swf_assets.empty? ? [0] : swf_assets.map(&:id)
)
pet_state.swf_assets = swf_assets
pet_state
end
# Helper to create a SwfAsset for biology (pet layers)
def build_biology_asset(zone, body_id:)
@remote_id = (@remote_id || 0) + 1
SwfAsset.create!(
type: "biology",
remote_id: @remote_id,
url: "https://images.neopets.example/biology_#{@remote_id}.swf",
zone: zone,
body_id: body_id,
zones_restrict: "",
has_image: true
)
end
# Helper to create a SwfAsset for items (object layers)
def build_item_asset(zone, body_id:)
@remote_id = (@remote_id || 0) + 1
SwfAsset.create!(
type: "object",
remote_id: @remote_id,
url: "https://images.neopets.example/object_#{@remote_id}.swf",
zone: zone,
body_id: body_id,
zones_restrict: "",
has_image: true
)
end
# Helper to create an item with specific swf_assets
def build_item(name, swf_assets: [])
item = Item.create!(
name: name,
description: "Test item",
thumbnail_url: "https://images.neopets.example/thumbnail.png",
rarity: "Common",
price: 100,
zones_restrict: "",
species_support_ids: ""
)
swf_assets.each do |asset|
ParentSwfAssetRelationship.create!(
parent: item,
swf_asset: asset
)
end
item
end
before do
PetType.destroy_all
@pet_type = PetType.create!(
species: species(:acara),
color: colors(:blue),
body_id: 1,
created_at: Time.new(2005)
)
end
describe "#render" do
context "with a simple outfit" do
it "composites biology and item layers into a single PNG" do
# Create test PNG data
red_png = create_test_png(255, 0, 0) # Red pixel
blue_png = create_test_png(0, 0, 255) # Blue pixel
# Create biology and item assets
biology_asset = build_biology_asset(zones(:head), body_id: 1)
item_asset = build_item_asset(zones(:hat), body_id: 1)
# Stub HTTP requests for the actual image URLs that will be generated
stub_request(:get, biology_asset.image_url).
to_return(body: red_png, status: 200)
stub_request(:get, item_asset.image_url).
to_return(body: blue_png, status: 200)
# Build outfit
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
item = build_item("Test Hat", swf_assets: [item_asset])
outfit = Outfit.new(pet_state: pet_state)
outfit.item_ids = { worn: [item.id], closeted: [] }
# Render
renderer = OutfitImageRenderer.new(outfit)
result = renderer.render
# Verify we got PNG data back
expect(result).not_to be_nil
expect(result).to be_a(String)
expect(result[0..7]).to eq("\x89PNG\r\n\x1A\n".b) # PNG magic bytes
# Verify the result is a valid 600x600 PNG
image = Vips::Image.new_from_buffer(result, "")
expect(image.width).to eq(600)
expect(image.height).to eq(600)
end
end
context "when a layer image fails to load" do
it "skips the failed layer and continues" do
blue_png = create_test_png(0, 0, 255)
biology_asset = build_biology_asset(zones(:head), body_id: 1)
item_asset = build_item_asset(zones(:hat), body_id: 1)
# Stub one successful request and one failure
stub_request(:get, biology_asset.image_url).
to_return(status: 404)
stub_request(:get, item_asset.image_url).
to_return(body: blue_png, status: 200)
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
item = build_item("Test Hat", swf_assets: [item_asset])
outfit = Outfit.new(pet_state: pet_state)
outfit.item_ids = { worn: [item.id], closeted: [] }
renderer = OutfitImageRenderer.new(outfit)
result = renderer.render
# Should still render successfully with just the one layer
expect(result).not_to be_nil
expect(result[0..7]).to eq("\x89PNG\r\n\x1A\n".b)
end
end
context "when no layers have images" do
it "returns nil" do
# Create an asset but stub image_url to return nil
biology_asset = build_biology_asset(zones(:head), body_id: 1)
allow_any_instance_of(SwfAsset).to receive(:image_url?).and_return(false)
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
outfit = Outfit.new(pet_state: pet_state)
renderer = OutfitImageRenderer.new(outfit)
result = renderer.render
expect(result).to be_nil
end
end
end
end

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

@ -0,0 +1,546 @@
require_relative '../rails_helper'
RSpec.describe Outfit do
fixtures :zones, :colors, :species
# Helper to create a pet state with specific swf_assets
def build_pet_state(pet_type, pose: "HAPPY_FEM", swf_assets: [])
pet_state = PetState.create!(
pet_type: pet_type,
pose: pose,
swf_asset_ids: swf_assets.empty? ? [0] : swf_assets.map(&:id)
)
pet_state.swf_assets = swf_assets
pet_state
end
# Helper to create a SwfAsset for biology (pet layers)
def build_biology_asset(zone, body_id:, zones_restrict: "")
@remote_id = (@remote_id || 0) + 1
SwfAsset.create!(
type: "biology",
remote_id: @remote_id,
url: "https://images.neopets.example/biology_#{@remote_id}.swf",
zone: zone,
body_id: body_id,
zones_restrict: zones_restrict
)
end
# Helper to create a SwfAsset for items (object layers)
def build_item_asset(zone, body_id:, zones_restrict: "")
@remote_id = (@remote_id || 0) + 1
SwfAsset.create!(
type: "object",
remote_id: @remote_id,
url: "https://images.neopets.example/object_#{@remote_id}.swf",
zone: zone,
body_id: body_id,
zones_restrict: zones_restrict
)
end
# Helper to create an item with specific swf_assets
def build_item(name, swf_assets: [])
item = Item.create!(
name: name,
description: "Test item",
thumbnail_url: "https://images.neopets.example/thumbnail.png",
rarity: "Common",
price: 100,
zones_restrict: "",
species_support_ids: ""
)
swf_assets.each do |asset|
ParentSwfAssetRelationship.create!(
parent: item,
swf_asset: asset
)
end
item
end
describe "#visible_layers" do
before do
# Clean up any existing pet types to avoid conflicts
PetType.destroy_all
# Create a basic pet type for testing
@pet_type = PetType.create!(
species: species(:acara),
color: colors(:blue),
body_id: 1,
created_at: Time.new(2005)
)
end
context "basic layer composition" do
it "returns pet layers when no items are worn" do
# Create biology assets for the pet
head = build_biology_asset(zones(:head), body_id: 1)
body = build_biology_asset(zones(:body), body_id: 1)
pet_state = build_pet_state(@pet_type, swf_assets: [head, body])
outfit = Outfit.new(pet_state: pet_state)
layers = outfit.visible_layers
expect(layers).to contain_exactly(head, body)
end
it "returns pet layers and item layers when items are worn" do
# Create pet layers
head = build_biology_asset(zones(:head), body_id: 1)
body = build_biology_asset(zones(:body), body_id: 1)
pet_state = build_pet_state(@pet_type, swf_assets: [head, body])
# Create item layers
hat_asset = build_item_asset(zones(:hat), body_id: 1)
hat = build_item("Test Hat", swf_assets: [hat_asset])
outfit = Outfit.new(pet_state: pet_state)
outfit.worn_items = [hat]
layers = outfit.visible_layers
expect(layers).to contain_exactly(head, body, hat_asset)
end
it "includes body_id=0 items that fit all pets" do
# Create pet layers
head = build_biology_asset(zones(:head), body_id: 1)
pet_state = build_pet_state(@pet_type, swf_assets: [head])
# Create a background item (body_id=0, fits all)
bg_asset = build_item_asset(zones(:background), body_id: 0)
background = build_item("Test Background", swf_assets: [bg_asset])
outfit = Outfit.new(pet_state: pet_state)
outfit.worn_items = [background]
layers = outfit.visible_layers
expect(layers).to contain_exactly(head, bg_asset)
end
end
context "items restricting pet layers (Rule 3a)" do
it "hides pet layers in zones that items restrict" do
# Create pet layers including hair
head = build_biology_asset(zones(:head), body_id: 1)
hair = build_biology_asset(zones(:hairfront), body_id: 1)
pet_state = build_pet_state(@pet_type, swf_assets: [head, hair])
# Create a hat that restricts the hair zone
# zones_restrict is a bitfield where position 37 (Hair Front zone id) is "1"
zones_restrict = "0" * 36 + "1" + "0" * 20 # bit 37 = 1
hat_asset = build_item_asset(zones(:hat), body_id: 1, zones_restrict: zones_restrict)
hat = build_item("Hair-hiding Hat", swf_assets: [hat_asset])
outfit = Outfit.new(pet_state: pet_state)
outfit.worn_items = [hat]
layers = outfit.visible_layers
# Hair should be hidden, but head and hat should be visible
expect(layers).to contain_exactly(head, hat_asset)
expect(layers).not_to include(hair)
end
it "hides multiple pet layers when item restricts multiple zones" do
# Create pet layers
head = build_biology_asset(zones(:head), body_id: 1)
hair_front = build_biology_asset(zones(:hairfront), body_id: 1)
head_transient = build_biology_asset(zones(:headtransientbiology), body_id: 1)
pet_state = build_pet_state(@pet_type, swf_assets: [head, hair_front, head_transient])
# Create an item that restricts both Hair Front (37) and Head Transient Biology (38)
zones_restrict = "0" * 36 + "11" + "0" * 20 # bits 37 and 38 = 1
hood_asset = build_item_asset(zones(:hat), body_id: 1, zones_restrict: zones_restrict)
hood = build_item("Agent Hood", swf_assets: [hood_asset])
outfit = Outfit.new(pet_state: pet_state)
outfit.worn_items = [hood]
layers = outfit.visible_layers
# Both hair_front and head_transient should be hidden
expect(layers).to contain_exactly(head, hood_asset)
expect(layers).not_to include(hair_front, head_transient)
end
end
context "pets restricting body-specific item layers (Rule 3b)" do
it "hides body-specific items in zones the pet restricts" do
# Create a pet with a layer that restricts the Static zone (46)
head = build_biology_asset(zones(:head), body_id: 1)
zones_restrict = "0" * 45 + "1" + "0" * 10 # bit 46 = 1
restricting_layer = build_biology_asset(zones(:body), body_id: 1, zones_restrict: zones_restrict)
pet_state = build_pet_state(@pet_type, swf_assets: [head, restricting_layer])
# Create a body-specific Static item
static_asset = build_item_asset(zones(:static), body_id: 1)
static_item = build_item("Body-specific Static", swf_assets: [static_asset])
outfit = Outfit.new(pet_state: pet_state)
outfit.worn_items = [static_item]
layers = outfit.visible_layers
# The body-specific static item should be hidden
expect(layers).to contain_exactly(head, restricting_layer)
expect(layers).not_to include(static_asset)
end
it "allows body_id=0 items even in zones the pet restricts" do
# Create a pet with a layer that restricts the Background Item zone (48)
# Background Item is type_id 3 (universal zone), so body_id=0 items should always work
head = build_biology_asset(zones(:head), body_id: 1)
zones_restrict = "0" * 47 + "1" + "0" * 10 # bit 48 = 1
restricting_layer = build_biology_asset(zones(:body), body_id: 1, zones_restrict: zones_restrict)
pet_state = build_pet_state(@pet_type, swf_assets: [head, restricting_layer])
# Create a body_id=0 Background Item (fits all bodies, universal zone)
bg_item_asset = build_item_asset(zones(:backgrounditem), body_id: 0)
bg_item = build_item("Universal Background Item", swf_assets: [bg_item_asset])
outfit = Outfit.new(pet_state: pet_state)
outfit.worn_items = [bg_item]
layers = outfit.visible_layers
# The body_id=0 item should be visible even though the zone is restricted
expect(layers).to contain_exactly(head, restricting_layer, bg_item_asset)
end
end
context "UNCONVERTED pets (Rule 3b special case)" do
it "rejects all body-specific items" do
# Create an UNCONVERTED pet
head = build_biology_asset(zones(:head), body_id: 1)
body = build_biology_asset(zones(:body), body_id: 1)
pet_state = build_pet_state(@pet_type, pose: "UNCONVERTED", swf_assets: [head, body])
# Create both body-specific and body_id=0 items
body_specific_asset = build_item_asset(zones(:hat), body_id: 1)
body_specific_item = build_item("Body-specific Hat", swf_assets: [body_specific_asset])
universal_asset = build_item_asset(zones(:background), body_id: 0)
universal_item = build_item("Universal Background", swf_assets: [universal_asset])
outfit = Outfit.new(pet_state: pet_state)
outfit.worn_items = [body_specific_item, universal_item]
layers = outfit.visible_layers
# Only body_id=0 items should be visible
expect(layers).to contain_exactly(head, body, universal_asset)
expect(layers).not_to include(body_specific_asset)
end
it "rejects body-specific items regardless of zone restrictions" do
# Create an UNCONVERTED pet with no zone restrictions
head = build_biology_asset(zones(:head), body_id: 1)
pet_state = build_pet_state(@pet_type, pose: "UNCONVERTED", swf_assets: [head])
# Create a body-specific item in a zone the pet doesn't restrict
hat_asset = build_item_asset(zones(:hat), body_id: 1)
hat = build_item("Body-specific Hat", swf_assets: [hat_asset])
outfit = Outfit.new(pet_state: pet_state)
outfit.worn_items = [hat]
layers = outfit.visible_layers
# The body-specific item should still be hidden
expect(layers).to contain_exactly(head)
expect(layers).not_to include(hat_asset)
end
end
context "pets restricting their own layers (Rule 3c)" do
it "hides pet layers in zones the pet itself restricts" do
# Create a pet with a horn asset and a layer that restricts the horn's zone
# (Simulating the Wraith Uni case)
body = build_biology_asset(zones(:body), body_id: 1)
# Create a horn in the Head Transient Biology zone (38)
horn = build_biology_asset(zones(:headtransientbiology), body_id: 1)
# Create a layer that restricts zone 38
zones_restrict = "0" * 37 + "1" + "0" * 20 # bit 38 = 1
restricting_layer = build_biology_asset(zones(:head), body_id: 1, zones_restrict: zones_restrict)
pet_state = build_pet_state(@pet_type, swf_assets: [body, horn, restricting_layer])
outfit = Outfit.new(pet_state: pet_state)
layers = outfit.visible_layers
# The horn should be hidden by the pet's own restrictions
expect(layers).to contain_exactly(body, restricting_layer)
expect(layers).not_to include(horn)
end
it "applies self-restrictions in combination with item restrictions" do
# Create a pet with multiple layers, some restricted by itself
body = build_biology_asset(zones(:body), body_id: 1)
hair = build_biology_asset(zones(:hairfront), body_id: 1)
# Pet restricts its own Head zone (30)
zones_restrict = "0" * 29 + "1" + "0" * 25 # bit 30 = 1
head = build_biology_asset(zones(:head), body_id: 1)
restricting_layer = build_biology_asset(zones(:eyes), body_id: 1, zones_restrict: zones_restrict)
pet_state = build_pet_state(@pet_type, swf_assets: [body, hair, head, restricting_layer])
# Add an item that restricts Hair Front (37)
item_zones_restrict = "0" * 36 + "1" + "0" * 20 # bit 37 = 1
hat_asset = build_item_asset(zones(:hat), body_id: 1, zones_restrict: item_zones_restrict)
hat = build_item("Hair-hiding Hat", swf_assets: [hat_asset])
outfit = Outfit.new(pet_state: pet_state)
outfit.worn_items = [hat]
layers = outfit.visible_layers
# Hair should be hidden by item, Head should be hidden by pet's own restrictions
expect(layers).to contain_exactly(body, restricting_layer, hat_asset)
expect(layers).not_to include(hair, head)
end
end
context "depth sorting and layer ordering" do
it "sorts layers by zone depth" do
# Create layers in various zones with different depths
background = build_biology_asset(zones(:background), body_id: 1) # depth 3
body_layer = build_biology_asset(zones(:body), body_id: 1) # depth 18
head_layer = build_biology_asset(zones(:head), body_id: 1) # depth 34
pet_state = build_pet_state(@pet_type, swf_assets: [head_layer, background, body_layer])
outfit = Outfit.new(pet_state: pet_state)
layers = outfit.visible_layers
# Should be sorted by depth: background (3) < body (18) < head (34)
expect(layers[0]).to eq(background)
expect(layers[1]).to eq(body_layer)
expect(layers[2]).to eq(head_layer)
end
it "places item layers after pet layers at the same depth" do
# Create a pet layer and item layer in zones with the same depth
# Static zone has depth 48
pet_static = build_biology_asset(zones(:static), body_id: 1)
pet_state = build_pet_state(@pet_type, swf_assets: [pet_static])
item_static = build_item_asset(zones(:static), body_id: 0)
static_item = build_item("Static Item", swf_assets: [item_static])
outfit = Outfit.new(pet_state: pet_state)
outfit.worn_items = [static_item]
layers = outfit.visible_layers
# Both should be present, with item layer last (on top)
expect(layers).to eq([pet_static, item_static])
end
it "sorts complex outfits correctly by depth" do
# Create a complex outfit with multiple pet and item layers
background = build_biology_asset(zones(:background), body_id: 1) # depth 3
body_layer = build_biology_asset(zones(:body), body_id: 1) # depth 18
head_layer = build_biology_asset(zones(:head), body_id: 1) # depth 34
pet_state = build_pet_state(@pet_type, swf_assets: [head_layer, background, body_layer])
# Add items at various depths
bg_item = build_item_asset(zones(:backgrounditem), body_id: 0) # depth 4
hat_asset = build_item_asset(zones(:hat), body_id: 1) # depth 16
shirt_asset = build_item_asset(zones(:shirtdress), body_id: 1) # depth 26
bg = build_item("Background Item", swf_assets: [bg_item])
hat = build_item("Hat", swf_assets: [hat_asset])
shirt = build_item("Shirt", swf_assets: [shirt_asset])
outfit = Outfit.new(pet_state: pet_state)
outfit.worn_items = [hat, bg, shirt]
layers = outfit.visible_layers
# Expected order by depth:
# background (3), bg_item (4), hat_asset (16), body_layer (18),
# shirt_asset (26), head_layer (34)
expect(layers.map(&:depth)).to eq([3, 4, 16, 18, 26, 34])
expect(layers).to eq([background, bg_item, hat_asset, body_layer, shirt_asset, head_layer])
end
end
context "alt styles (alternative pet appearances)" do
before do
# Create an alt style with its own body_id distinct from regular pets
@alt_style = AltStyle.create!(
species: species(:acara),
color: colors(:blue),
body_id: 999, # Distinct from the regular pet's body_id (1)
series_name: "Nostalgic",
thumbnail_url: "https://images.neopets.example/alt_style.png"
)
end
it "uses alt style layers instead of pet state layers" do
# Create regular pet layers
regular_head = build_biology_asset(zones(:head), body_id: 1)
regular_body = build_biology_asset(zones(:body), body_id: 1)
pet_state = build_pet_state(@pet_type, swf_assets: [regular_head, regular_body])
# Create alt style layers (with the alt style's body_id)
alt_head = build_biology_asset(zones(:head), body_id: 999)
alt_body = build_biology_asset(zones(:body), body_id: 999)
@alt_style.swf_assets = [alt_head, alt_body]
# Create outfit with alt_style
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
layers = outfit.visible_layers
# Should use alt style layers, not pet state layers
expect(layers).to contain_exactly(alt_head, alt_body)
expect(layers).not_to include(regular_head, regular_body)
end
it "only includes body_id=0 items with alt styles" do
# Create alt style layers
alt_head = build_biology_asset(zones(:head), body_id: 999)
@alt_style.swf_assets = [alt_head]
pet_state = build_pet_state(@pet_type)
# Create a body-specific item for the alt style's body_id
body_specific_asset = build_item_asset(zones(:hat), body_id: 999)
body_specific_item = build_item("Body-specific Hat", swf_assets: [body_specific_asset])
# Create a universal item (body_id=0)
universal_asset = build_item_asset(zones(:background), body_id: 0)
universal_item = build_item("Universal Background", swf_assets: [universal_asset])
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
outfit.worn_items = [body_specific_item, universal_item]
layers = outfit.visible_layers
# Only the universal item should appear
expect(layers).to contain_exactly(alt_head, universal_asset)
expect(layers).not_to include(body_specific_asset)
end
it "does not include items from the regular pet's body_id" do
# Create alt style layers
alt_body = build_biology_asset(zones(:body), body_id: 999)
@alt_style.swf_assets = [alt_body]
pet_state = build_pet_state(@pet_type)
# Create an item that fits the regular pet's body_id (1)
regular_item_asset = build_item_asset(zones(:hat), body_id: 1)
regular_item = build_item("Regular Pet Hat", swf_assets: [regular_item_asset])
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
outfit.worn_items = [regular_item]
layers = outfit.visible_layers
# The regular pet item should not appear on the alt style
expect(layers).to contain_exactly(alt_body)
expect(layers).not_to include(regular_item_asset)
end
it "applies item restriction rules with alt styles" do
# Create alt style layers including hair
alt_head = build_biology_asset(zones(:head), body_id: 999)
alt_hair = build_biology_asset(zones(:hairfront), body_id: 999)
@alt_style.swf_assets = [alt_head, alt_hair]
pet_state = build_pet_state(@pet_type)
# Create a universal hat that restricts the hair zone
zones_restrict = "0" * 36 + "1" + "0" * 20 # bit 37 (Hair Front) = 1
hat_asset = build_item_asset(zones(:hat), body_id: 0, zones_restrict: zones_restrict)
hat = build_item("Hair-hiding Hat", swf_assets: [hat_asset])
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
outfit.worn_items = [hat]
layers = outfit.visible_layers
# Hair should be hidden by the hat's zone restrictions
expect(layers).to contain_exactly(alt_head, hat_asset)
expect(layers).not_to include(alt_hair)
end
it "applies pet restriction rules with alt styles" do
# Create alt style with a layer that restricts a zone
alt_head = build_biology_asset(zones(:head), body_id: 999)
zones_restrict = "0" * 47 + "1" + "0" * 10 # bit 48 (Background Item) = 1
restricting_layer = build_biology_asset(zones(:body), body_id: 999, zones_restrict: zones_restrict)
@alt_style.swf_assets = [alt_head, restricting_layer]
pet_state = build_pet_state(@pet_type)
# Create a universal Background Item
bg_item_asset = build_item_asset(zones(:backgrounditem), body_id: 0)
bg_item = build_item("Universal Background Item", swf_assets: [bg_item_asset])
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
outfit.worn_items = [bg_item]
layers = outfit.visible_layers
# body_id=0 items should still appear even in restricted zones
# (because they're not body-specific)
expect(layers).to contain_exactly(alt_head, restricting_layer, bg_item_asset)
end
it "applies self-restriction rules with alt styles" do
# Create alt style that restricts its own horn layer
alt_body = build_biology_asset(zones(:body), body_id: 999)
alt_horn = build_biology_asset(zones(:headtransientbiology), body_id: 999)
zones_restrict = "0" * 37 + "1" + "0" * 20 # bit 38 (Head Transient Biology) = 1
restricting_layer = build_biology_asset(zones(:head), body_id: 999, zones_restrict: zones_restrict)
@alt_style.swf_assets = [alt_body, alt_horn, restricting_layer]
pet_state = build_pet_state(@pet_type)
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
layers = outfit.visible_layers
# The horn should be hidden by the alt style's own restrictions
expect(layers).to contain_exactly(alt_body, restricting_layer)
expect(layers).not_to include(alt_horn)
end
it "sorts alt style and item layers by depth correctly" do
# Create alt style layers at various depths
alt_background = build_biology_asset(zones(:background), body_id: 999) # depth 3
alt_body = build_biology_asset(zones(:body), body_id: 999) # depth 18
alt_head = build_biology_asset(zones(:head), body_id: 999) # depth 34
@alt_style.swf_assets = [alt_head, alt_background, alt_body]
pet_state = build_pet_state(@pet_type)
# Add universal items at various depths
bg_item = build_item_asset(zones(:backgrounditem), body_id: 0) # depth 4
trinket = build_item_asset(zones(:righthanditem), body_id: 0) # depth 5
bg = build_item("Background Item", swf_assets: [bg_item])
trinket_item = build_item("Trinket", swf_assets: [trinket])
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
outfit.worn_items = [trinket_item, bg]
layers = outfit.visible_layers
# Expected order by depth:
# alt_background (3), bg_item (4), trinket (5), alt_body (18), alt_head (34)
expect(layers.map(&:depth)).to eq([3, 4, 5, 18, 34])
expect(layers).to eq([alt_background, bg_item, trinket, alt_body, alt_head])
end
end
end
end

Binary file not shown.

BIN
vendor/cache/ruby-vips-2.3.0.gem vendored Normal file

Binary file not shown.