Compare commits

..

No commits in common. "b03b32c538250ff5b5bf39df13cdc2f0c63a8d6b" and "f545510edcf2d5b73832f8b2d89a0aa25ddd2449" have entirely different histories.

148 changed files with 338 additions and 1748 deletions

24
.solargraph.yml Normal file
View file

@ -0,0 +1,24 @@
---
include:
- "app/**/*.rb"
- "config/**/*.rb"
exclude:
- "spec/**/*"
- "test/**/*"
- "vendor/**/*"
- ".bundle/**/*"
require:
- actioncable
- actionmailer
- actionpack
- actionview
- activemodel
- activerecord
- activesupport
plugins:
- solargraph-rails
domains: []
reporters:
- require_not_found
require_paths: []
max_files: 5000

27
Gemfile
View file

@ -11,11 +11,11 @@ gem 'mysql2', '~> 0.5.5'
# For reading the .env file, which you can use in development to more easily
# set environment variables for secret data.
gem 'dotenv', '~> 3.2'
gem 'dotenv-rails', '~> 2.8', '>= 2.8.1'
# For the asset pipeline: templates, CSS, JS, etc.
gem 'sprockets', '~> 4.2'
gem 'haml', '~> 7.2'
gem 'haml', '~> 6.1', '>= 6.1.1'
gem 'sass-rails', '~> 6.0'
gem 'terser', '~> 1.1', '>= 1.1.17'
gem 'jsbundling-rails', '~> 1.3'
@ -25,7 +25,7 @@ gem 'turbo-rails', '~> 2.0'
gem 'devise', '~> 4.9', '>= 4.9.2'
gem 'devise-encryptable', '~> 0.2.0'
gem 'omniauth', '~> 2.1'
gem 'omniauth-rails_csrf_protection', '~> 2.0', '>= 2.0.1'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
gem "omniauth_openid_connect", "~> 0.7.1"
# For pagination UI.
@ -40,7 +40,7 @@ gem 'nokogiri', '~> 1.15', '>= 1.15.3'
# For safely rendering users' Markdown + HTML on item list pages.
gem 'rdiscount', '~> 2.2', '>= 2.2.7.1'
gem 'sanitize', '~> 7.0'
gem 'sanitize', '~> 6.0', '>= 6.0.2'
# For working with Neopets APIs.
# unstable version of RocketAMF interprets info registry as a hash instead of an array
@ -61,9 +61,6 @@ gem "async", "~> 2.17", require: false
gem "async-http", "~> 0.89.0", require: false
gem "thread-local", "~> 1.1", require: false
# For image processing (outfit PNG rendering).
gem "ruby-vips", "~> 2.2"
# For debugging.
group :development do
gem 'debug', '~> 1.9.2'
@ -74,7 +71,7 @@ end
gem 'bootsnap', '~> 1.16', require: false
# For investigating performance issues.
gem 'rack-mini-profiler', '~> 4.0', '>= 4.0.1'
gem "rack-mini-profiler", "~> 3.1"
gem "memory_profiler", "~> 1.0"
gem "stackprof", "~> 0.2.25"
@ -85,6 +82,16 @@ gem "sentry-rails", "~> 5.12"
# For tasks that use shell commands.
gem "shell", "~> 0.8.1"
# For workspace autocomplete.
group :development do
gem "solargraph", "~> 0.50.0"
gem "solargraph-rails", "~> 1.1"
end
# For automated tests.
gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test]
gem "webmock", "~> 3.24", group: [:test]
group :development, :test do
gem "rspec-rails", "~> 7.0"
end
group :test do
gem "webmock", "~> 3.24"
end

View file

@ -6,31 +6,31 @@ PATH
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.16)
action_text-trix (2.1.15)
railties
actioncable (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
actioncable (8.1.1)
actionpack (= 8.1.1)
activesupport (= 8.1.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
actionmailbox (8.1.1)
actionpack (= 8.1.1)
activejob (= 8.1.1)
activerecord (= 8.1.1)
activestorage (= 8.1.1)
activesupport (= 8.1.1)
mail (>= 2.8.0)
actionmailer (8.1.2)
actionpack (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activesupport (= 8.1.2)
actionmailer (8.1.1)
actionpack (= 8.1.1)
actionview (= 8.1.1)
activejob (= 8.1.1)
activesupport (= 8.1.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.2)
actionview (= 8.1.2)
activesupport (= 8.1.2)
actionpack (8.1.1)
actionview (= 8.1.1)
activesupport (= 8.1.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@ -38,36 +38,36 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.2)
actiontext (8.1.1)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
actionpack (= 8.1.1)
activerecord (= 8.1.1)
activestorage (= 8.1.1)
activesupport (= 8.1.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.2)
activesupport (= 8.1.2)
actionview (8.1.1)
activesupport (= 8.1.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.2)
activesupport (= 8.1.2)
activejob (8.1.1)
activesupport (= 8.1.1)
globalid (>= 0.3.6)
activemodel (8.1.2)
activesupport (= 8.1.2)
activerecord (8.1.2)
activemodel (= 8.1.2)
activesupport (= 8.1.2)
activemodel (8.1.1)
activesupport (= 8.1.1)
activerecord (8.1.1)
activemodel (= 8.1.1)
activesupport (= 8.1.1)
timeout (>= 0.4.0)
activestorage (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activesupport (= 8.1.2)
activestorage (8.1.1)
actionpack (= 8.1.1)
activejob (= 8.1.1)
activerecord (= 8.1.1)
activesupport (= 8.1.1)
marcel (~> 1.0)
activesupport (8.1.2)
activesupport (8.1.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@ -83,13 +83,14 @@ GEM
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
aes_key_wrap (1.1.0)
async (2.35.3)
ast (2.4.3)
async (2.35.0)
console (~> 1.29)
fiber-annotation
io-event (~> 1.11)
metrics (~> 0.12)
traces (~> 0.18)
async-container (0.29.0)
async-container (0.27.7)
async (~> 2.22)
async-http (0.89.0)
async (>= 2.10.2)
@ -105,17 +106,19 @@ GEM
async-http (~> 0.56)
async-pool (0.11.1)
async (>= 2.0)
async-service (0.17.0)
async-service (0.16.0)
async
async-container (~> 0.28)
async-container (~> 0.16)
string-format (~> 0.2)
attr_required (1.0.2)
backport (1.2.0)
base64 (0.3.0)
bcrypt (3.1.21)
bcrypt (3.1.20)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindata (2.5.1)
bindex (0.8.1)
bootsnap (1.21.1)
bootsnap (1.20.1)
msgpack (~> 1.2)
builder (3.3.0)
childprocess (5.1.0)
@ -143,7 +146,10 @@ GEM
devise-encryptable (0.2.0)
devise (>= 2.1.0)
diff-lcs (1.6.2)
dotenv (3.2.0)
dotenv (2.8.1)
dotenv-rails (2.8.1)
dotenv (= 2.8.1)
railties (>= 3.2)
drb (2.2.3)
e2mmap (0.1.0)
email_validator (2.2.4)
@ -168,20 +174,21 @@ GEM
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.5.0)
faraday-follow_redirects (0.4.0)
faraday (>= 1, < 3)
faraday-net_http (3.4.2)
net-http (~> 0.5)
ffi (1.17.3-aarch64-linux-gnu)
ffi (1.17.3-arm64-darwin)
ffi (1.17.3-x86_64-linux-gnu)
ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
fiber-annotation (0.2.0)
fiber-local (1.1.0)
fiber-storage
fiber-storage (1.0.1)
globalid (1.3.0)
activesupport (>= 6.1)
haml (7.2.0)
haml (6.4.0)
temple (>= 0.8.2)
thor
tilt
@ -199,6 +206,7 @@ GEM
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jaro_winkler (1.6.1)
jsbundling-rails (1.3.1)
railties (>= 6.0.0)
json (2.18.0)
@ -209,13 +217,19 @@ GEM
bindata
faraday (~> 2.0)
faraday-follow_redirects
kramdown (2.5.1)
rexml (>= 3.3.9)
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
language_server-protocol (3.17.0.5)
launchy (3.1.1)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
localhost (1.7.0)
lint_roller (1.1.0)
localhost (1.6.0)
logger (1.7.0)
loofah (2.25.0)
crass (~> 1.0.2)
@ -231,6 +245,7 @@ GEM
memory_profiler (1.1.0)
metrics (0.15.0)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (6.0.1)
prism (~> 1.5)
msgpack (1.8.0)
@ -248,18 +263,21 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.0-aarch64-linux-gnu)
nokogiri (1.18.10)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.0-arm64-darwin)
nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-gnu)
nokogiri (1.18.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4)
omniauth (2.1.4)
hashie (>= 3.4.6)
logger
rack (>= 2.2.3)
rack-protection
omniauth-rails_csrf_protection (2.0.1)
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth_openid_connect (0.7.1)
@ -280,34 +298,38 @@ GEM
webfinger (~> 2.0)
openssl (3.3.2)
orm_adapter (0.5.0)
parallel (1.27.0)
parser (3.3.10.0)
ast (~> 2.4.1)
racc
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.8.0)
prism (1.7.0)
process-metrics (0.8.0)
console (~> 1.8)
json (~> 2)
samovar (~> 2.1)
protocol-hpack (1.5.1)
protocol-http (0.58.0)
protocol-http1 (0.36.0)
protocol-http (~> 0.58)
protocol-http2 (0.24.0)
protocol-http (0.56.1)
protocol-http1 (0.35.2)
protocol-http (~> 0.22)
protocol-http2 (0.23.0)
protocol-hpack (~> 1.4)
protocol-http (~> 0.47)
protocol-rack (0.21.0)
protocol-rack (0.19.0)
io-stream (>= 0.10)
protocol-http (~> 0.58)
protocol-http (~> 0.43)
rack (>= 1.0)
psych (5.3.1)
date
stringio
public_suffix (7.0.2)
public_suffix (7.0.0)
racc (1.8.1)
rack (3.2.4)
rack-attack (6.8.0)
rack (>= 1.0, < 4)
rack-mini-profiler (4.0.1)
rack-mini-profiler (3.3.1)
rack (>= 1.2.0)
rack-oauth2 (2.3.0)
activesupport
@ -327,20 +349,20 @@ GEM
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.2)
actioncable (= 8.1.2)
actionmailbox (= 8.1.2)
actionmailer (= 8.1.2)
actionpack (= 8.1.2)
actiontext (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activemodel (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
rails (8.1.1)
actioncable (= 8.1.1)
actionmailbox (= 8.1.1)
actionmailer (= 8.1.1)
actionpack (= 8.1.1)
actiontext (= 8.1.1)
actionview (= 8.1.1)
activejob (= 8.1.1)
activemodel (= 8.1.1)
activerecord (= 8.1.1)
activestorage (= 8.1.1)
activesupport (= 8.1.1)
bundler (>= 1.15.0)
railties (= 8.1.2)
railties (= 8.1.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@ -351,26 +373,31 @@ GEM
rails-i18n (8.1.0)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
railties (8.1.1)
actionpack (= 8.1.1)
activesupport (= 8.1.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rbs (2.8.4)
rdiscount (2.2.7.3)
rdoc (7.1.0)
rdoc (7.0.3)
erb
psych (>= 4.0.0)
tsort
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
reverse_markdown (2.1.1)
nokogiri
rexml (3.4.4)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
@ -380,24 +407,36 @@ GEM
rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (8.0.2)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
rspec-rails (7.1.1)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.6)
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
rubocop (1.82.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.48.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.48.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
ruby-progressbar (1.13.0)
samovar (2.4.1)
console (~> 1.0)
mapping (~> 1.0)
sanitize (7.0.0)
sanitize (6.1.3)
crass (~> 1.0.2)
nokogiri (>= 1.16.8)
nokogiri (>= 1.12.0)
sass-rails (6.0.0)
sassc-rails (~> 2.1, >= 2.1.1)
sassc (2.4.0)
@ -418,6 +457,25 @@ GEM
shell (0.8.1)
e2mmap
sync
solargraph (0.50.0)
backport (~> 1.2)
benchmark
bundler (~> 2.0)
diff-lcs (~> 1.4)
e2mmap
jaro_winkler (~> 1.5)
kramdown (~> 2.3)
kramdown-parser-gfm (~> 1.1)
parser (~> 3.0)
rbs (~> 2.0)
reverse_markdown (~> 2.0)
rubocop (~> 1.38)
thor (~> 1.0)
tilt (~> 2.0)
yard (~> 0.9, >= 0.9.24)
solargraph-rails (1.2.4)
activesupport
solargraph (>= 0.48.0, <= 0.57)
sprockets (4.2.2)
concurrent-ruby (~> 1.0)
logger
@ -438,17 +496,20 @@ GEM
temple (0.10.4)
terser (1.2.6)
execjs (>= 0.3.0, < 3)
thor (1.5.0)
thor (1.4.0)
thread-local (1.1.0)
tilt (2.7.0)
tilt (2.6.1)
timeout (0.6.0)
traces (0.18.2)
tsort (0.2.0)
turbo-rails (2.0.21)
turbo-rails (2.0.20)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
validate_url (1.0.15)
@ -474,11 +535,13 @@ GEM
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
will_paginate (4.0.1)
yard (0.9.38)
zeitwerk (2.7.4)
PLATFORMS
aarch64-linux
arm64-darwin
ruby
x86_64-linux
DEPENDENCIES
@ -490,9 +553,9 @@ DEPENDENCIES
debug (~> 1.9.2)
devise (~> 4.9, >= 4.9.2)
devise-encryptable (~> 0.2.0)
dotenv (~> 3.2)
dotenv-rails (~> 2.8, >= 2.8.1)
falcon (~> 0.48.0)
haml (~> 7.2)
haml (~> 6.1, >= 6.1.1)
http_accept_language (~> 2.1, >= 2.1.1)
jsbundling-rails (~> 1.3)
letter_opener (~> 1.8, >= 1.8.1)
@ -500,20 +563,21 @@ DEPENDENCIES
mysql2 (~> 0.5.5)
nokogiri (~> 1.15, >= 1.15.3)
omniauth (~> 2.1)
omniauth-rails_csrf_protection (~> 2.0, >= 2.0.1)
omniauth-rails_csrf_protection (~> 1.0)
omniauth_openid_connect (~> 0.7.1)
rack-attack (~> 6.7)
rack-mini-profiler (~> 4.0, >= 4.0.1)
rack-mini-profiler (~> 3.1)
rails (~> 8.0, >= 8.0.1)
rails-i18n (~> 8.0, >= 8.0.1)
rdiscount (~> 2.2, >= 2.2.7.1)
rspec-rails (~> 8.0, >= 8.0.2)
ruby-vips (~> 2.2)
sanitize (~> 7.0)
rspec-rails (~> 7.0)
sanitize (~> 6.0, >= 6.0.2)
sass-rails (~> 6.0)
sentry-rails (~> 5.12)
sentry-ruby (~> 5.12)
shell (~> 0.8.1)
solargraph (~> 0.50.0)
solargraph-rails (~> 1.1)
sprockets (~> 4.2)
stackprof (~> 0.2.25)
terser (~> 1.1, >= 1.1.17)

View file

@ -150,13 +150,13 @@ function updateStage() {
function updateCanvasDimensions() {
// Set the canvas's internal dimensions to be higher, if the device has high
// DPI. Scale the stage to match, too.
// DPI. Scale the movie clip to match, too.
const internalWidth = canvas.offsetWidth * window.devicePixelRatio;
const internalHeight = canvas.offsetHeight * window.devicePixelRatio;
canvas.width = internalWidth;
canvas.height = internalHeight;
stage.scaleX = internalWidth / library.properties.width;
stage.scaleY = internalHeight / library.properties.height;
movieClip.scaleX = internalWidth / library.properties.width;
movieClip.scaleY = internalHeight / library.properties.height;
}
window.addEventListener("resize", () => {
@ -176,28 +176,23 @@ window.addEventListener("resize", () => {
////////////////////////////////////////////////////
async function startMovie() {
// Install the MotionGuidePlugin, which is needed for motion path animations.
createjs.MotionGuidePlugin.install();
// Load the movie's library (from the JS file already run), and use it to
// build a movie clip.
library = await getLibrary();
movieClip = buildMovieClip(library);
updateCanvasDimensions();
if (canvas.getContext("2d") == null) {
console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
// TODO: "Too many animations!"
return;
}
stage = new library.Stage(canvas);
stage = new window.createjs.Stage(canvas);
stage.addChild(movieClip);
updateCanvasDimensions();
updateStage();
// Signal to the library that the composition is ready.
AdobeAn.compositionLoaded(library.properties.id);
loadingStatus = "loaded";
canvas.setAttribute("data-status", "loaded");

View file

@ -12,9 +12,11 @@ class ApplicationController < ActionController::Base
before_action :save_return_to_path,
if: ->(c) { c.controller_name == 'sessions' && c.action_name == 'new' }
# Enable profiling tools in development or when logged in as an admin.
# Enable profiling tools if logged in as admin.
before_action do
Rack::MiniProfiler.authorize_request if Rails.env.development? || current_user&.admin?
if current_user && current_user.admin?
Rack::MiniProfiler.authorize_request
end
end
class AccessDenied < StandardError; end

View file

@ -3,12 +3,9 @@ class ItemTradesController < ApplicationController
@item = Item.find params[:item_id]
@type = type_from_params
@item_trades = @item.visible_trades(
scope: ClosetHanger.includes(:user, :list).
order('users.last_trade_activity_at DESC'),
user: current_user,
remote_ip: request.remote_ip
)
@item_trades = @item.closet_hangers.trading.includes(:user, :list).
user_is_active.order('users.last_trade_activity_at DESC').
to_trades(current_user, request.remote_ip)
@trades = @item_trades[@type]
if user_signed_in?

View file

@ -80,10 +80,8 @@ class ItemsController < ApplicationController
respond_to do |format|
format.html do
@trades = @item.visible_trades(
user: current_user,
remote_ip: request.remote_ip
)
@trades = @item.closet_hangers.trading.user_is_active.
to_trades(current_user, request.remote_ip)
@contributors_with_counts = @item.contributors_with_counts
@ -109,15 +107,6 @@ class ItemsController < ApplicationController
includes(:species).merge(Species.alphabetical)
end
format.json do
render json: @item.as_json(
include_trade_counts: true,
include_nc_trade_value: true,
current_user: current_user,
remote_ip: request.remote_ip
)
end
format.gif do
expires_in 1.month
redirect_to @item.thumbnail_url, allow_other_host: true

View file

@ -13,26 +13,7 @@ class OutfitsController < ApplicationController
end
def edit
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
render "outfits/edit", layout: false
end
def index
@ -137,40 +118,6 @@ class OutfitsController < ApplicationController
biology: [:species_id, :color_id, :pose, :pet_state_id])
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
raise ActiveRecord::RecordNotFound unless user_signed_in?
@outfit = current_user.outfits.find(params[:id])

View file

@ -142,13 +142,6 @@ module ItemsHelper
AUCTION_GENIE_URL_TEMPLATE.expand(auctiongenie: item.name).to_s
end
LEBRON_URL_TEMPLATE = Addressable::Template.new(
"https://stylisher.club/search/{name}"
)
def lebron_url_for(item)
LEBRON_URL_TEMPLATE.expand(name: item.name).to_s
end
def format_contribution_count(count)
" (&times;#{count})".html_safe if count > 1
end

View file

@ -390,10 +390,6 @@ export function loadMovieLibrary(librarySrc, { preferArchive = false } = {}) {
);
}
delete window.AdobeAn.compositions[compositionId];
// Install the MotionGuidePlugin, which is needed for motion path animations.
window.createjs.MotionGuidePlugin.install();
const library = composition.getLibrary();
// One more loading step as part of loading this library is loading the

View file

@ -34,7 +34,7 @@ class Item < ApplicationRecord
attr_writer :current_body_id, :owned, :wanted
NCRarities = [0, 500]
PAINTBRUSH_SET_DESCRIPTION = 'This item is part of a deluxe paint brush set'
PAINTBRUSH_SET_DESCRIPTION = 'This item is part of a deluxe paint brush set!'
scope :newest, -> {
order(arel_table[:created_at].desc) if arel_table[:created_at]
@ -162,7 +162,7 @@ class Item < ApplicationRecord
end
def pb?
I18n.with_locale(:en) { self.description.include?(PAINTBRUSH_SET_DESCRIPTION) }
I18n.with_locale(:en) { self.description == PAINTBRUSH_SET_DESCRIPTION }
end
def np?
@ -444,34 +444,11 @@ class Item < ApplicationRecord
created_at || Time.new(2010)
end
# Returns the visible trades for this item, filtered by user visibility.
# Accepts an optional scope to add additional query constraints (e.g., includes, order).
def visible_trades(scope: nil, user: nil, remote_ip: nil)
base = closet_hangers.trading.user_is_active
base = base.merge(scope) if scope
base.to_trades(user, remote_ip)
end
def as_json(options={})
result = super({
super({
only: [:id, :name, :description, :thumbnail_url, :rarity_index],
methods: [:zones_restrict],
}.merge(options))
if options[:include_trade_counts]
trades = visible_trades(
user: options[:current_user],
remote_ip: options[:remote_ip]
)
result['num_trades_offering'] = trades[:offering].size
result['num_trades_seeking'] = trades[:seeking].size
end
if options[:include_nc_trade_value]
result['nc_trade_value'] = nc_trade_value
end
result
end
def compatible_body_ids(use_cached: true)

View file

@ -172,67 +172,52 @@ class Outfit < ApplicationRecord
def visible_layers
return [] if pet_state.nil?
# Step 1: Choose biology layers - use alt style if present, otherwise pet state
if alt_style
biology_layers = alt_style.swf_assets.includes(:zone).to_a
body = alt_style
using_alt_style = true
else
biology_layers = pet_state.swf_assets.includes(:zone).to_a
body = pet_type
using_alt_style = false
end
# TODO: This method doesn't currently handle alt styles! If the outfit has
# an alt_style, we should use its layers instead of pet_state layers, and
# filter items to only those with body_id=0. This isn't needed yet because
# this method is only used on item pages, which don't support alt styles.
# See useOutfitAppearance.js for the complete logic including alt styles.
item_appearances = item_appearances(swf_asset_includes: [:zone])
# Step 2: Load item appearances for the appropriate body
item_appearances = Item.appearances_for(
worn_items,
body,
swf_asset_includes: [:zone]
).values
pet_layers = pet_state.swf_assets.includes(:zone).to_a
item_layers = item_appearances.map(&:swf_assets).flatten
# 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).
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
# Rule 3a: When an item restricts a zone, it hides biology layers of the same zone.
# 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!
biology_layers.reject! { |sa| item_restricted_zone_ids.include?(sa.zone_id) }
pet_layers.reject! { |sa| item_restricted_zone_ids.include?(sa.zone_id) }
# Rule 3b: When a biology 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?)
# 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 biology layers and items occupying the same
# 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 biology appearance's *occupied* zones in
# 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 biology layers! So, we now only check *restricted* zones.
# 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
@ -249,20 +234,18 @@ class Outfit < ApplicationRecord
item_layers.reject! { |sa| sa.body_specific? }
else
item_layers.reject! { |sa| sa.body_specific? &&
biology_restricted_zone_ids.include?(sa.zone_id) }
pet_restricted_zone_ids.include?(sa.zone_id) }
end
# Rule 3c: A biology appearance can also restrict its own zones. The Wraith
# Uni is an interesting example: it has a horn, but its zone restrictions
# hide it!
biology_layers.reject! { |sa| biology_restricted_zone_ids.include?(sa.zone_id) }
# 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) }
# Step 4: Sort by depth and return
(biology_layers + item_layers).sort_by(&:depth)
(pet_layers + item_layers).sort_by(&:depth)
end
def wardrobe_params
params = {
{
name: name,
color: color_id,
species: species_id,
@ -271,8 +254,6 @@ class Outfit < ApplicationRecord
objects: worn_item_ids,
closet: closeted_item_ids,
}
params[:style] = alt_style_id if alt_style_id.present?
params
end
def ensure_unique_name

View file

@ -1,63 +0,0 @@
# Pet::AutoModeling provides utilities for automatically modeling items on pet
# bodies using the NC Mall preview API. This allows us to fetch appearance data
# for items without needing a real pet of that type.
#
# The workflow:
# 1. Generate a combined "SCI" (Species/Color Image hash) using NC Mall's
# getPetData endpoint, which combines a pet type with items.
# 2. Fetch the viewer data for that combined SCI using the CustomPets API.
# 3. Process the viewer data to create SwfAsset records.
module Pet::AutoModeling
extend self
# Model an item on a specific body ID. This fetches the appearance data from
# Neopets and creates/updates the SwfAsset records.
#
# @param item [Item] The item to model
# @param body_id [Integer] The body ID to model on
# @return [Symbol] Result status:
# - :modeled - Successfully created SwfAsset records
# - :not_compatible - Item is explicitly not compatible with this body
# @raise [NoPetTypeForBody] If no PetType exists for this body_id
# @raise [Neopets::NCMall::ResponseNotOK] On HTTP errors (transient for 5xx)
# @raise [Neopets::NCMall::UnexpectedResponseFormat] On invalid response
# @raise [Neopets::CustomPets::DownloadError] On AMF protocol errors
def model_item_on_body(item, body_id)
# Find a pet type with this body ID to use as a base
pet_type = PetType.find_by(body_id: body_id)
raise NoPetTypeForBody.new(body_id) if pet_type.nil?
# Fetch the viewer data for this item on this pet type
new_image_hash = Neopets::NCMall.fetch_pet_data_sci(pet_type.image_hash, [item.id])
viewer_data = Neopets::CustomPets.fetch_viewer_data("@#{new_image_hash}")
# If the item wasn't in the response, it's not compatible.
object_info = viewer_data[:object_info_registry]&.to_h&.[](item.id.to_s)
return :not_compatible if object_info.nil?
# Process the modeling data using the existing infrastructure
snapshot = Pet::ModelingSnapshot.new(viewer_data)
# Save the pet type (may update image hash, etc.)
snapshot.pet_type.save!
# Get the items from the snapshot and process them
modeled_items = snapshot.items
modeled_item = modeled_items.find { |i| i.id == item.id }
if modeled_item
modeled_item.save!
modeled_item.handle_assets!
end
:modeled
end
class NoPetTypeForBody < StandardError
attr_reader :body_id
def initialize(body_id)
@body_id = body_id
super("No PetType found for body_id=#{body_id}")
end
end
end

View file

@ -120,44 +120,6 @@ module Neopets::NCMall
end
end
# Generate a new image hash for a pet wearing specific items. Takes a base
# pet sci (species/color image hash) and optional item IDs, and returns a
# response containing the combined image hash in the :newsci field.
# Use the returned hash with Neopets::CustomPets.fetch_viewer_data("@#{newsci}")
# to get the full appearance data.
PET_DATA_URL = "https://ncmall.neopets.com/mall/ajax/petview/getPetData.php"
def self.fetch_pet_data_sci(pet_sci, item_ids = [])
Sync do
params = {"selPetsci" => pet_sci}
item_ids.each { |id| params["itemsList[]"] = id.to_s }
DTIRequests.post(
PET_DATA_URL,
[["Content-Type", "application/x-www-form-urlencoded"]],
params.to_query,
) do |response|
if response.status != 200
raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{PET_DATA_URL})"
end
begin
data = JSON.parse(response.read)
rescue JSON::ParserError
raise UnexpectedResponseFormat,
"failed to parse pet data response as JSON"
end
unless data["newsci"].is_a?(String) && data["newsci"].present?
raise UnexpectedResponseFormat,
"missing or invalid field newsci in pet data response"
end
data["newsci"]
end
end
end
private
# Map load_type from menu JSON to the v2 API type parameter.

View file

@ -15,13 +15,10 @@
= link_to t('items.show.resources.jn_items'), jn_items_url_for(item)
= link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item)
- if item.nc_trade_value
= link_to lebron_url_for(item),
= link_to 'https://www.neopets.com/~lebron',
title: nc_trade_value_updated_at_text(item.nc_trade_value) do
= t 'items.show.resources.lebron_value',
= t 'items.show.resources.lebron',
value: nc_trade_value_estimate_text(item.nc_trade_value)
- elsif item.nc?
= link_to lebron_url_for(item) do
= t 'items.show.resources.lebron'
- unless item.nc?
= link_to t('items.show.resources.shop_wizard'), shop_wizard_url_for(item)
= link_to t('items.show.resources.trading_post'), trading_post_url_for(item)

27
bin/solargraph Executable file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# This file was generated by Bundler.
#
# The application 'solargraph' is installed as part of a gem, and
# this file is here to facilitate running it.
#
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
bundle_binstub = File.expand_path("bundle", __dir__)
if File.file?(bundle_binstub)
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
load(bundle_binstub)
else
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
end
end
require "rubygems"
require "bundler/setup"
load Gem.bin_path("solargraph", "solargraph")

View file

@ -310,8 +310,7 @@ en:
resources:
jn_items: JN Items
impress_2020: DTI 2020
lebron: Lebron
lebron_value: "Lebron: %{value}"
lebron: "Lebron: %{value}"
shop_wizard: Shop Wizard
trading_post: Trades
auction_genie: Auctions

View file

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

View file

@ -249,28 +249,6 @@ This crowdsourced approach is why DTI is "self-sustaining" - users passively con
See `app/models/pet/modeling_snapshot.rb` for the full implementation.
### Potential Upgrade: Auto-Modeling
We are currently not making full use of a recently-discovered Neopets feature: **we no longer need a real pet to model
the item**. There's an NC Mall feature that supports previews of *any* item, not just those sold in the Mall.
See the `pets:load` task for implementation details.
We still need users to show us new items in the first place, to learn their item IDs and what body types they might
fit. But once we have that, we can proactively attempt to model the pet on all relevant body types.
Let's pursue this in two steps:
1. [x] Create a backfill Rake task to attempt to load any models we suspect we need, based on the same logic as the
`-is:modeled` item search filter.
- Consider having this task auto-update the `modeling_status_hint` field on the item, if we've demonstrated that
the item is almost certainly completely modeled, despite our heuristic indicating it is not. This will keep the
`is:modeled` filter clean and approximately empty.
- As part of this, let's refactor the logic out of `pets:load`, to more simply construct an "image hash" ("sci")
from a pet type + items combination.
2. [ ] Set this as a cron job to run very frequently, to quickly load in new items.
- If we're able to reliably keep `is:modeled` basically empty, this could even be safe to run every, say, 25min.
### Cached Fields
To avoid expensive queries, several models cache computed data:

View file

@ -1,82 +0,0 @@
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, "")
# Resize the layer to fit the canvas size
# All layer images are square, but may not be CANVAS_SIZE x CANVAS_SIZE
# We need to resize them to exactly CANVAS_SIZE x CANVAS_SIZE
if layer_image.width != CANVAS_SIZE || layer_image.height != CANVAS_SIZE
layer_image = layer_image.resize(
CANVAS_SIZE.to_f / layer_image.width,
vscale: CANVAS_SIZE.to_f / layer_image.height
)
end
# Composite this layer onto the canvas at (0, 0)
# No offset needed since the layer is now exactly canvas-sized
canvas = canvas.composite([layer_image], :over)
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

@ -30,17 +30,24 @@ module RocketAMFExtensions
raise RocketAMF::AMFError.new(first_message_data)
end
# HACK: Older items in Neopets' database have Windows-1250 encoding,
# while newer items use proper UTF-8. We detect which encoding was used
# by checking if the string is valid UTF-8, and only re-encode if needed.
# HACK: It seems to me that these messages come back with Windows-1250
# (or similar) encoding on the strings? I'm basing this on the
# Patchwork Staff item, whose description arrives as:
#
# Example of Windows-1250 item: Patchwork Staff (57311), whose
# description contains byte 0x96 (en-dash in Windows-1250).
# "That staff is cute, but dont use it as a walking stick \x96 I " +
# "dont think it will hold you up!"
#
# Example of UTF-8 item: Carnival Party Décor (80042), whose name
# contains proper UTF-8 bytes [195, 169] for the é character.
# And the `\x96` is meant to represent an endash, which it doesn't in
# UTF-8 or in most extended ASCII encodings, but *does* in Windows's
# specific extended ASCII.
#
# Idk if this is something to do with the AMFPHP spec or how the AMFPHP
# server code they use serializes strings (I couldn't find any
# reference to it?), or just their internal database encoding being
# passed along as-is, or what? But this seems to be the most correct
# interpretation I know how to do, so, let's do it!
result.messages[0].data.body.tap do |body|
reencode_strings_if_needed! body, "Windows-1250", "UTF-8"
reencode_strings! body, "Windows-1250", "UTF-8"
end
end
@ -85,17 +92,13 @@ module RocketAMFExtensions
end
end
def reencode_strings_if_needed!(target, from, to)
def reencode_strings!(target, from, to)
if target.is_a? String
# Only re-encode if the string is not valid UTF-8
# (indicating it's in the old Windows-1250 encoding)
unless target.valid_encoding?
target.force_encoding(from).encode!(to)
end
target.force_encoding(from).encode!(to)
elsif target.is_a? Array
target.each { |x| reencode_strings_if_needed!(x, from, to) }
target.each { |x| reencode_strings!(x, from, to) }
elsif target.is_a? Hash
target.values.each { |x| reencode_strings_if_needed!(x, from, to) }
target.values.each { |x| reencode_strings!(x, from, to) }
end
end
end

View file

@ -9,90 +9,4 @@ namespace :items do
end
end
end
desc "Auto-model items on missing body types using NC Mall preview API"
task :auto_model, [:limit] => :environment do |task, args|
limit = (args[:limit] || 100).to_i
dry_run = ENV["DRY_RUN"] == "1"
auto_hint = ENV["AUTO_HINT"] != "0"
puts "Auto-modeling up to #{limit} items#{dry_run ? ' (DRY RUN)' : ''}..."
puts "Auto-hint: #{auto_hint ? 'enabled' : 'disabled'}"
puts
# Find items that need modeling, newest first
items = Item.is_not_modeled.order(created_at: :desc).limit(limit)
puts "Found #{items.count} items to process"
puts
items.each_with_index do |item, index|
puts "[#{index + 1}/#{items.count}] Item ##{item.id}: #{item.name}"
missing_body_ids = item.predicted_missing_body_ids
if missing_body_ids.empty?
puts " ⚠️ No missing body IDs (item may already be fully modeled)"
puts
next
end
puts " Missing #{missing_body_ids.size} body IDs: #{missing_body_ids.join(', ')}"
# Track results for this item
results = {modeled: 0, not_compatible: 0, not_found: 0}
had_transient_error = false
missing_body_ids.each do |body_id|
if dry_run
puts " Body #{body_id}: [DRY RUN] would attempt modeling"
next
end
begin
result = Pet::AutoModeling.model_item_on_body(item, body_id)
results[result] += 1
case result
when :modeled
puts " Body #{body_id}: ✅ Modeled successfully"
when :not_compatible
puts " Body #{body_id}: ❌ Not compatible (heuristic over-predicted)"
end
rescue Pet::AutoModeling::NoPetTypeForBody => e
puts " Body #{body_id}: ⚠️ #{e.message}"
rescue Neopets::NCMall::ResponseNotOK => e
if e.status >= 500
puts " Body #{body_id}: ⚠️ Server error (#{e.status}), will retry later"
had_transient_error = true
else
puts " Body #{body_id}: ❌ HTTP error (#{e.status})"
Sentry.capture_exception(e)
end
rescue Neopets::NCMall::UnexpectedResponseFormat => e
puts " Body #{body_id}: ❌ Unexpected response format: #{e.message}"
Sentry.capture_exception(e)
rescue Neopets::CustomPets::DownloadError => e
puts " Body #{body_id}: ⚠️ AMF error: #{e.message}"
had_transient_error = true
end
end
unless dry_run
# Set hint if we've addressed all bodies without transient errors.
# That way, if the item is not compatible with some bodies, we'll stop
# trying to auto-model it.
if auto_hint && !had_transient_error
item.update!(modeling_status_hint: "done")
puts " 📋 Set modeling_status_hint = 'done'"
end
end
puts " Summary: #{results[:modeled]} modeled, #{results[:not_compatible]} not compatible, #{results[:not_found]} not found"
puts
# Be nice to Neopets API
sleep 0.5 unless dry_run || index == items.count - 1
end
puts "Done!"
end
end

View file

@ -1,30 +1,7 @@
namespace :pets do
desc "Load a pet's viewer data (by name or by color/species/items)"
task :load, [:first] => [:environment] do |task, args|
# Collect all arguments (first + extras)
all_args = [args[:first]] + args.extras
# If only one argument, treat it as a pet name
if all_args.length == 1
viewer_data = Neopets::CustomPets.fetch_viewer_data(all_args[0])
else
# Multiple arguments: color, species, and optional item IDs
color_name = all_args[0]
species_name = all_args[1]
item_ids = all_args[2..]
# Look up the PetType to use for the preview
pet_type = PetType.matching_name(color_name, species_name).first!
# Convert it to an image hash for direct lookup
new_image_hash = Neopets::NCMall.fetch_pet_data_sci(pet_type.image_hash, item_ids)
pet_name = '@' + new_image_hash
$stderr.puts "Loading pet #{pet_name}"
# Load the image hash as if it were a pet
viewer_data = Neopets::CustomPets.fetch_viewer_data(pet_name)
end
desc "Load a pet's viewer data"
task :load, [:name] => [:environment] do |task, args|
viewer_data = Neopets::CustomPets.fetch_viewer_data(args[:name])
puts JSON.pretty_generate(viewer_data)
end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

@ -28,9 +28,9 @@ hindbiology:
type_id: 1
label: Hind Biology
plain_label: hindbiology
markings1:
id: 6
depth: 8
markings:
id: 31
depth: 35
type_id: 2
label: Markings
plain_label: markings
@ -88,12 +88,6 @@ body:
type_id: 1
label: Body
plain_label: body
markings2:
id: 16
depth: 19
type_id: 2
label: Markings
plain_label: markings
bodydisease:
id: 17
depth: 20
@ -178,12 +172,6 @@ head:
type_id: 1
label: Head
plain_label: head
markings3:
id: 31
depth: 35
type_id: 2
label: Markings
plain_label: markings
headdisease:
id: 32
depth: 36
@ -208,9 +196,9 @@ glasses:
type_id: 2
label: Glasses
plain_label: glasses
earrings1:
id: 36
depth: 39
earrings:
id: 41
depth: 45
type_id: 2
label: Earrings
plain_label: earrings
@ -232,21 +220,15 @@ headdrippings:
type_id: 1
label: Head Drippings
plain_label: headdrippings
hat1:
id: 40
depth: 44
hat:
id: 50
depth: 16
type_id: 2
label: Hat
plain_label: hat
earrings2:
id: 41
depth: 45
type_id: 2
label: Earrings
plain_label: earrings
righthanditem1:
id: 42
depth: 46
righthanditem:
id: 49
depth: 5
type_id: 2
label: Right-hand Item
plain_label: righthanditem
@ -286,18 +268,6 @@ backgrounditem:
type_id: 3
label: Background Item
plain_label: backgrounditem
righthanditem2:
id: 49
depth: 5
type_id: 2
label: Right-hand Item
plain_label: righthanditem
hat2:
id: 50
depth: 16
type_id: 2
label: Hat
plain_label: hat
belt:
id: 51
depth: 27

View file

@ -1,240 +0,0 @@
require 'webmock/rspec'
require_relative '../rails_helper'
RSpec.describe OutfitImageRenderer do
fixtures :zones, :colors, :species
# Helper to load a fixture image
def load_fixture_image(filename)
path = Rails.root.join('spec', 'fixtures', 'outfit_images', filename)
File.read(path)
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
# Load fixture images
acara_png = load_fixture_image('Blue Acara.png')
hat_png = load_fixture_image('Hat.png')
expected_composite_png = load_fixture_image('Blue Acara With Hat.png')
# Create biology and item assets
biology_asset = build_biology_asset(zones(:head), body_id: 1)
item_asset = build_item_asset(zones(:hat1), 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: acara_png, status: 200)
stub_request(:get, item_asset.image_url).
to_return(body: hat_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,
worn_items: [item]
)
# 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
result_image = Vips::Image.new_from_buffer(result, "")
expect(result_image.width).to eq(600)
expect(result_image.height).to eq(600)
# Verify the composite matches the expected image pixel-perfectly
expected_image = Vips::Image.new_from_buffer(expected_composite_png, "")
# Calculate the absolute difference between images
diff = (result_image - expected_image).abs
max_diff = diff.max
# Allow a small tolerance for minor encoding/compositing differences
# The expected image was generated with a different method, so we expect
# very close but not necessarily pixel-perfect matches
tolerance = 2
if max_diff > tolerance
debug_path = Rails.root.join('tmp', 'test_render_result.png')
result_image.write_to_file(debug_path.to_s)
fail "Images should match within tolerance of #{tolerance}, but found max difference of #{max_diff}. Actual output saved to #{debug_path}"
end
end
end
context "when a layer image fails to load" do
it "skips the failed layer and continues" do
hat_png = load_fixture_image('Hat.png')
biology_asset = build_biology_asset(zones(:head), body_id: 1)
item_asset = build_item_asset(zones(:hat1), 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: hat_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,
worn_items: [item]
)
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
it "resizes all layers to 600x600 before compositing" do
# Load a 1200x1200 item layer (real-world case from Neopets)
item_1200_png = load_fixture_image('Cape.png')
acara_600_png = load_fixture_image('Blue Acara.png')
expected_composite_png = load_fixture_image('Blue Acara With Cape.png')
# Create assets
biology_asset = build_biology_asset(zones(:head), body_id: 1)
item_asset = build_item_asset(zones(:hat1), body_id: 1)
# Stub HTTP requests
stub_request(:get, biology_asset.image_url).
to_return(body: acara_600_png, status: 200)
stub_request(:get, item_asset.image_url).
to_return(body: item_1200_png, status: 200)
# Build outfit
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
item = build_item("Test Item", swf_assets: [item_asset])
outfit = Outfit.new(
pet_state: pet_state,
worn_items: [item]
)
# Render
renderer = OutfitImageRenderer.new(outfit)
result = renderer.render
# Verify we got valid PNG data
expect(result).not_to be_nil
expect(result).to be_a(String)
expect(result[0..7]).to eq("\x89PNG\r\n\x1A\n".b)
# Verify the result is exactly 600x600
result_image = Vips::Image.new_from_buffer(result, "")
expect(result_image.width).to eq(600)
expect(result_image.height).to eq(600)
# Verify the composite matches the expected image pixel-perfectly
expected_image = Vips::Image.new_from_buffer(expected_composite_png, "")
# Calculate the absolute difference between images
diff = (result_image - expected_image).abs
max_diff = diff.max
# Allow a small tolerance for minor encoding/compositing differences
tolerance = 2
if max_diff > tolerance
debug_path = Rails.root.join('tmp', 'test_render_1200_result.png')
result_image.write_to_file(debug_path.to_s)
fail "Images should match within tolerance of #{tolerance}, but found max difference of #{max_diff}. Actual output saved to #{debug_path}"
end
end
end
end

View file

@ -1,7 +1,7 @@
require_relative '../rails_helper'
RSpec.describe Outfit do
fixtures :zones, :colors, :species
fixtures :colors, :species, :zones
let(:blue) { colors(:blue) }
let(:acara) { species(:acara) }
@ -54,7 +54,7 @@ RSpec.describe Outfit do
describe "Item::Appearance#compatible_with?" do
it "returns true for items in different zones with no restrictions" do
hat = create_item("Hat", zones(:hat1))
hat = create_item("Hat", zones(:hat))
shirt = create_item("Shirt", zones(:shirtdress))
appearances = Item.appearances_for([hat, shirt], @pet_type)
@ -66,8 +66,8 @@ RSpec.describe Outfit do
end
it "returns false for items in the same zone" do
hat1 = create_item("Hat 1", zones(:hat1))
hat2 = create_item("Hat 2", zones(:hat1))
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]
@ -89,7 +89,7 @@ RSpec.describe Outfit do
zones_restrict_array[28] = "1" # Set bit for zone 29
zones_restrict = zones_restrict_array.join
restricting_hat = create_item("Restricting Hat", zones(:hat1), zones_restrict: zones_restrict)
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))
@ -104,7 +104,7 @@ RSpec.describe Outfit do
it "returns true for empty appearances" do
# Create items that don't fit the current pet (wrong body_id)
hat = create_item("Hat", zones(:hat1), body_id: 999)
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)
@ -122,7 +122,7 @@ RSpec.describe Outfit do
describe "#without_item" do
it "returns a new outfit without the given item" do
hat = create_item("Hat", zones(:hat1))
hat = create_item("Hat", zones(:hat))
outfit_with_hat = @outfit.with_item(hat)
new_outfit = outfit_with_hat.without_item(hat)
@ -132,7 +132,7 @@ RSpec.describe Outfit do
end
it "returns a new outfit instance (immutable)" do
hat = create_item("Hat", zones(:hat1))
hat = create_item("Hat", zones(:hat))
outfit_with_hat = @outfit.with_item(hat)
new_outfit = outfit_with_hat.without_item(hat)
@ -142,7 +142,7 @@ RSpec.describe Outfit do
end
it "does nothing if the item is not worn" do
hat = create_item("Hat", zones(:hat1))
hat = create_item("Hat", zones(:hat))
new_outfit = @outfit.without_item(hat)
@ -152,7 +152,7 @@ RSpec.describe Outfit do
describe "#with_item" do
it "adds an item when there are no conflicts" do
hat = create_item("Hat", zones(:hat1))
hat = create_item("Hat", zones(:hat))
new_outfit = @outfit.with_item(hat)
@ -160,7 +160,7 @@ RSpec.describe Outfit do
end
it "returns a new outfit instance (immutable)" do
hat = create_item("Hat", zones(:hat1))
hat = create_item("Hat", zones(:hat))
new_outfit = @outfit.with_item(hat)
@ -170,7 +170,7 @@ RSpec.describe Outfit do
end
it "is idempotent (adding same item twice has no effect)" do
hat = create_item("Hat", zones(:hat1))
hat = create_item("Hat", zones(:hat))
outfit1 = @outfit.with_item(hat)
outfit2 = outfit1.with_item(hat)
@ -182,7 +182,7 @@ RSpec.describe Outfit do
it "does not add items that don't fit this pet" do
# Create item with wrong body_id
hat = create_item("Hat", zones(:hat1), body_id: 999)
hat = create_item("Hat", zones(:hat), body_id: 999)
new_outfit = @outfit.with_item(hat)
@ -191,8 +191,8 @@ RSpec.describe Outfit do
context "with conflicting items" do
it "moves conflicting item to closet when items occupy the same zone" do
hat1 = create_item("Hat 1", zones(:hat1))
hat2 = create_item("Hat 2", zones(:hat1))
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)
@ -211,7 +211,7 @@ RSpec.describe Outfit do
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(:hat1), zones_restrict: zones_restrict)
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)
@ -223,7 +223,7 @@ RSpec.describe Outfit do
end
it "keeps compatible items when adding new item" do
hat = create_item("Hat", zones(:hat1))
hat = create_item("Hat", zones(:hat))
shirt = create_item("Shirt", zones(:shirtdress))
pants = create_item("Pants", zones(:trousers))
@ -235,9 +235,9 @@ RSpec.describe Outfit do
end
it "can move multiple conflicting items to closet" do
hat1 = create_item("Hat 1", zones(:hat1))
hat2 = create_item("Hat 2", zones(:hat1))
hat3 = create_item("Hat 3", zones(:hat1))
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)
@ -253,8 +253,8 @@ RSpec.describe Outfit do
end
it "does not duplicate items in closet if already closeted" do
hat1 = create_item("Hat 1", zones(:hat1))
hat2 = create_item("Hat 2", zones(:hat1))
hat1 = create_item("Hat 1", zones(:hat))
hat2 = create_item("Hat 2", zones(:hat))
# Wear hat1
outfit1 = @outfit.with_item(hat1)
@ -278,552 +278,11 @@ RSpec.describe Outfit do
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(:hat1))
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
# 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(:hat1), 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(:hat1), 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(:hat1), 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(:hat1), 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(:hat1), 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(:hat1), 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(:hat1), body_id: 1) # depth 44
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), body_layer (18), shirt_asset (26),
# head_layer (34), hat_asset (44)
expect(layers.map(&:depth)).to eq([3, 4, 18, 26, 34, 44])
expect(layers).to eq([background, bg_item, body_layer, shirt_asset, head_layer, hat_asset])
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(:hat1), 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(:hat1), 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(:hat1), 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(:righthanditem1), body_id: 0) # depth 46
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), alt_body (18), alt_head (34), trinket (46)
expect(layers.map(&:depth)).to eq([3, 4, 18, 34, 46])
expect(layers).to eq([alt_background, bg_item, alt_body, alt_head, trinket])
end
end
end
end

View file

@ -1,92 +0,0 @@
require_relative '../../rails_helper'
require_relative '../../support/mocks/custom_pets'
require_relative '../../support/mocks/nc_mall'
RSpec.describe Pet::AutoModeling, type: :model do
fixtures :colors, :species, :zones
# Set up a Purple Chia pet type (body_id 212) for testing
let!(:pet_type) do
PetType.create!(
species_id: Species.find_by_name!("chia").id,
color_id: Color.find_by_name!("purple").id,
body_id: 212,
image_hash: "purpchia"
)
end
# A known compatible item for testing (exists in mock data)
let(:compatible_item) do
Item.create!(
id: 71706,
name: "On the Roof Background",
description: "Who is that on the roof?! Could it be...?",
thumbnail_url: "https://images.neopets.com/items/gif_roof_onthe_fg.gif",
rarity: "Special",
rarity_index: 101,
price: 0,
zones_restrict: "0000000000000000000000000000000000000000000000000000"
)
end
describe ".model_item_on_body" do
context "when item is compatible with the body" do
let(:item) { compatible_item }
it "returns :modeled" do
result = Pet::AutoModeling.model_item_on_body(item, 212)
expect(result).to eq :modeled
end
it "creates SwfAsset records for the item" do
expect {
Pet::AutoModeling.model_item_on_body(item, 212)
}.to change { SwfAsset.where(type: "object").count }.by(1)
end
it "associates the SwfAsset with the item" do
Pet::AutoModeling.model_item_on_body(item, 212)
item.reload
asset = item.swf_assets.find_by(remote_id: 410722)
expect(asset).to be_present
expect(asset.body_id).to eq 0 # This item fits all bodies
expect(asset.zone_id).to eq 3
end
end
context "when item is not in the response" do
let(:item) do
# Create an item that won't be in our mock response
Item.create!(
id: 99999,
name: "Nonexistent Item",
description: "This item doesn't exist in the mock",
thumbnail_url: "https://example.com/item.gif",
rarity: "Special",
rarity_index: 101,
price: 0,
zones_restrict: "0000000000000000000000000000000000000000000000000000"
)
end
it "returns :not_compatible" do
result = Pet::AutoModeling.model_item_on_body(item, 212)
expect(result).to eq :not_compatible
end
end
context "when no PetType exists for the body_id" do
let(:item) { compatible_item }
it "raises NoPetTypeForBody" do
expect {
Pet::AutoModeling.model_item_on_body(item, 99999)
}.to raise_error(Pet::AutoModeling::NoPetTypeForBody) do |error|
expect(error.body_id).to eq 99999
expect(error.message).to include "99999"
end
end
end
end
end

View file

@ -3,14 +3,9 @@ module Neopets::CustomPets
DATA_DIR = Pathname.new(__dir__) / "custom_pets"
def self.fetch_viewer_data(pet_name, ...)
# NOTE: Windows doesn't support `@` in filenames, so we use a `scis` directory instead.
path = if pet_name.start_with?('@')
DATA_DIR / "scis" / "#{pet_name[1..]}.json"
else
DATA_DIR / "#{pet_name}.json"
File.open(DATA_DIR / "#{pet_name}.json") do |file|
HashWithIndifferentAccess.new JSON.load(file)
end
File.open(path) { |f| HashWithIndifferentAccess.new JSON.load(f) }
end
def self.fetch_metadata(...)

View file

@ -1,69 +0,0 @@
{
"custom_pet": {
"name": "@mock:m:thyass:39552",
"owner": "",
"slot": 1.0,
"scale": 0.5,
"muted": true,
"body_id": 212.0,
"species_id": 6.0,
"color_id": 61.0,
"alt_style": false,
"alt_color": 61.0,
"style_closet_id": null,
"biology_by_zone": {
"37": {
"part_id": 10083.0,
"zone_id": 37.0,
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/010/10083_8a1111a13f.swf",
"manifest": "https://images.neopets.com/cp/bio/data/000/000/010/10083_8a1111a13f/manifest.json",
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
},
"15": {
"part_id": 11613.0,
"zone_id": 15.0,
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/011/11613_f7d8d377ab.swf",
"manifest": "https://images.neopets.com/cp/bio/data/000/000/011/11613_f7d8d377ab/manifest.json",
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
},
"34": {
"part_id": 14187.0,
"zone_id": 34.0,
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/014/14187_0e65c2082f.swf",
"manifest": "https://images.neopets.com/cp/bio/data/000/000/014/14187_0e65c2082f/manifest.json",
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
},
"33": {
"part_id": 14189.0,
"zone_id": 33.0,
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/014/14189_102e4991e9.swf",
"manifest": "https://images.neopets.com/cp/bio/data/000/000/014/14189_102e4991e9/manifest.json",
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
}
},
"equipped_by_zone": {},
"original_biology": []
},
"closet_items": {},
"object_info_registry": {
"39552": {
"obj_info_id": 39552.0,
"assets_by_zone": {},
"zones_restrict": "0000000000000000000000000000000000000000000000000000",
"is_compatible": false,
"is_paid": true,
"thumbnail_url": "https://images.neopets.com/items/mall_springyeyeglasses.gif",
"name": "Springy Eye Glasses",
"description": "Hey, keep your eyes in your head!",
"category": "Clothes",
"type": "Clothes",
"rarity": "Artifact",
"rarity_index": 500.0,
"price": 0.0,
"weight_lbs": 1.0,
"species_support": [3.0],
"converted": true
}
},
"object_asset_registry": {}
}

View file

@ -1,85 +0,0 @@
{
"custom_pet": {
"name": "@mock:m:thyass:71706",
"owner": "",
"slot": 1.0,
"scale": 0.5,
"muted": true,
"body_id": 212.0,
"species_id": 6.0,
"color_id": 61.0,
"alt_style": false,
"alt_color": 61.0,
"style_closet_id": null,
"biology_by_zone": {
"37": {
"part_id": 10083.0,
"zone_id": 37.0,
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/010/10083_8a1111a13f.swf",
"manifest": "https://images.neopets.com/cp/bio/data/000/000/010/10083_8a1111a13f/manifest.json",
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
},
"15": {
"part_id": 11613.0,
"zone_id": 15.0,
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/011/11613_f7d8d377ab.swf",
"manifest": "https://images.neopets.com/cp/bio/data/000/000/011/11613_f7d8d377ab/manifest.json",
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
},
"34": {
"part_id": 14187.0,
"zone_id": 34.0,
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/014/14187_0e65c2082f.swf",
"manifest": "https://images.neopets.com/cp/bio/data/000/000/014/14187_0e65c2082f/manifest.json",
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
},
"33": {
"part_id": 14189.0,
"zone_id": 33.0,
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/014/14189_102e4991e9.swf",
"manifest": "https://images.neopets.com/cp/bio/data/000/000/014/14189_102e4991e9/manifest.json",
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
}
},
"equipped_by_zone": {
"3": {
"asset_id": 410722.0,
"zone_id": 3.0,
"closet_obj_id": 0.0
}
},
"original_biology": []
},
"closet_items": {},
"object_info_registry": {
"71706": {
"obj_info_id": 71706.0,
"assets_by_zone": {
"3": 410722.0
},
"zones_restrict": "0000000000000000000000000000000000000000000000000000",
"is_compatible": true,
"is_paid": false,
"thumbnail_url": "https://images.neopets.com/items/gif_roof_onthe_fg.gif",
"name": "On the Roof Background",
"description": "Who is that on the roof?! Could it be...?",
"category": "Special",
"type": "Mystical Surroundings",
"rarity": "Special",
"rarity_index": 101.0,
"price": 0.0,
"weight_lbs": 1.0,
"species_support": [],
"converted": true
}
},
"object_asset_registry": {
"410722": {
"asset_id": 410722.0,
"zone_id": 3.0,
"asset_url": "https://images.neopets.com/cp/items/swf/000/000/410/410722_3bcd2f5e11.swf",
"obj_info_id": 71706.0,
"manifest": "https://images.neopets.com/cp/items/data/000/000/410/410722_3bcd2f5e11/manifest.json?v=1706"
}
}
}

View file

@ -1,24 +0,0 @@
{
"custom_pet": {
"name": "@purpchia:99999",
"body_id": 212.0,
"species_id": 6.0,
"color_id": 61.0,
"alt_style": false,
"alt_color": 61.0,
"biology_by_zone": {
"15": {
"part_id": 11613.0,
"zone_id": 15.0,
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/011/11613_f7d8d377ab.swf",
"manifest": "https://images.neopets.com/cp/bio/data/000/000/011/11613_f7d8d377ab/manifest.json",
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
}
},
"equipped_by_zone": [],
"original_biology": []
},
"closet_items": [],
"object_info_registry": [],
"object_asset_registry": []
}

View file

@ -1,8 +0,0 @@
# We replace Neopets::NCMall.fetch_pet_data_sci with a mocked implementation.
module Neopets::NCMall
# Mock implementation that generates predictable SCI hashes for testing.
# The hash is derived from the pet_sci and item_ids to ensure consistency.
def self.fetch_pet_data_sci(pet_sci, item_ids = [])
"#{pet_sci}-#{item_ids.sort.join('-')}"
end
end

BIN
vendor/cache/action_text-trix-2.1.15.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/actioncable-8.1.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/actionmailbox-8.1.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/actionmailer-8.1.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/actionpack-8.1.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/actiontext-8.1.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/actionview-8.1.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/activejob-8.1.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/activemodel-8.1.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/activerecord-8.1.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/activestorage-8.1.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/activesupport-8.1.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/ast-2.4.3.gem vendored Normal file

Binary file not shown.

BIN
vendor/cache/async-2.35.0.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/async-container-0.27.7.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/async-service-0.16.0.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/backport-1.2.0.gem vendored Normal file

Binary file not shown.

BIN
vendor/cache/bcrypt-3.1.20.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/benchmark-0.5.0.gem vendored Normal file

Binary file not shown.

BIN
vendor/cache/bootsnap-1.20.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/dotenv-2.8.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/dotenv-rails-2.8.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/ffi-1.17.2-arm64-darwin.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/ffi-1.17.2.gem vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/haml-6.4.0.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/jaro_winkler-1.6.1.gem vendored Normal file

Binary file not shown.

BIN
vendor/cache/kramdown-2.5.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/lint_roller-1.1.0.gem vendored Normal file

Binary file not shown.

BIN
vendor/cache/localhost-1.6.0.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/mini_portile2-2.8.9.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/nokogiri-1.18.10.gem vendored Normal file

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more