Compare commits
28 commits
f545510edc
...
b03b32c538
| Author | SHA1 | Date | |
|---|---|---|---|
| b03b32c538 | |||
| 04ed182cef | |||
| fff8079a63 | |||
| 08a64d4987 | |||
| dd2f6be79f | |||
| 6aff39dfed | |||
| 5f342ae0ee | |||
| e9235d0b40 | |||
| 790f8a3016 | |||
| d0c8c9d9c0 | |||
| 359f368d80 | |||
| 8a34fe76a2 | |||
| 91fba090fa | |||
| 38956030ed | |||
| 56bb87f54f | |||
| 59d1f5bae8 | |||
| dcbdf17e56 | |||
| c241dc33b0 | |||
| 83281591b3 | |||
| 7430e12655 | |||
| a9c9f94dde | |||
| 4e00f5d1af | |||
| aa45ea17b3 | |||
| 55fa50c22a | |||
| f823bac717 | |||
| cf80f96410 | |||
| 23e951edcd | |||
| aac3e5a5a9 |
148 changed files with 1748 additions and 338 deletions
|
|
@ -1,24 +0,0 @@
|
||||||
---
|
|
||||||
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
27
Gemfile
|
|
@ -11,11 +11,11 @@ gem 'mysql2', '~> 0.5.5'
|
||||||
|
|
||||||
# For reading the .env file, which you can use in development to more easily
|
# For reading the .env file, which you can use in development to more easily
|
||||||
# set environment variables for secret data.
|
# set environment variables for secret data.
|
||||||
gem 'dotenv-rails', '~> 2.8', '>= 2.8.1'
|
gem 'dotenv', '~> 3.2'
|
||||||
|
|
||||||
# For the asset pipeline: templates, CSS, JS, etc.
|
# For the asset pipeline: templates, CSS, JS, etc.
|
||||||
gem 'sprockets', '~> 4.2'
|
gem 'sprockets', '~> 4.2'
|
||||||
gem 'haml', '~> 6.1', '>= 6.1.1'
|
gem 'haml', '~> 7.2'
|
||||||
gem 'sass-rails', '~> 6.0'
|
gem 'sass-rails', '~> 6.0'
|
||||||
gem 'terser', '~> 1.1', '>= 1.1.17'
|
gem 'terser', '~> 1.1', '>= 1.1.17'
|
||||||
gem 'jsbundling-rails', '~> 1.3'
|
gem 'jsbundling-rails', '~> 1.3'
|
||||||
|
|
@ -25,7 +25,7 @@ gem 'turbo-rails', '~> 2.0'
|
||||||
gem 'devise', '~> 4.9', '>= 4.9.2'
|
gem 'devise', '~> 4.9', '>= 4.9.2'
|
||||||
gem 'devise-encryptable', '~> 0.2.0'
|
gem 'devise-encryptable', '~> 0.2.0'
|
||||||
gem 'omniauth', '~> 2.1'
|
gem 'omniauth', '~> 2.1'
|
||||||
gem 'omniauth-rails_csrf_protection', '~> 1.0'
|
gem 'omniauth-rails_csrf_protection', '~> 2.0', '>= 2.0.1'
|
||||||
gem "omniauth_openid_connect", "~> 0.7.1"
|
gem "omniauth_openid_connect", "~> 0.7.1"
|
||||||
|
|
||||||
# For pagination UI.
|
# For pagination UI.
|
||||||
|
|
@ -40,7 +40,7 @@ gem 'nokogiri', '~> 1.15', '>= 1.15.3'
|
||||||
|
|
||||||
# For safely rendering users' Markdown + HTML on item list pages.
|
# For safely rendering users' Markdown + HTML on item list pages.
|
||||||
gem 'rdiscount', '~> 2.2', '>= 2.2.7.1'
|
gem 'rdiscount', '~> 2.2', '>= 2.2.7.1'
|
||||||
gem 'sanitize', '~> 6.0', '>= 6.0.2'
|
gem 'sanitize', '~> 7.0'
|
||||||
|
|
||||||
# For working with Neopets APIs.
|
# For working with Neopets APIs.
|
||||||
# unstable version of RocketAMF interprets info registry as a hash instead of an array
|
# unstable version of RocketAMF interprets info registry as a hash instead of an array
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -71,7 +74,7 @@ end
|
||||||
gem 'bootsnap', '~> 1.16', require: false
|
gem 'bootsnap', '~> 1.16', require: false
|
||||||
|
|
||||||
# For investigating performance issues.
|
# For investigating performance issues.
|
||||||
gem "rack-mini-profiler", "~> 3.1"
|
gem 'rack-mini-profiler', '~> 4.0', '>= 4.0.1'
|
||||||
gem "memory_profiler", "~> 1.0"
|
gem "memory_profiler", "~> 1.0"
|
||||||
gem "stackprof", "~> 0.2.25"
|
gem "stackprof", "~> 0.2.25"
|
||||||
|
|
||||||
|
|
@ -82,16 +85,6 @@ gem "sentry-rails", "~> 5.12"
|
||||||
# For tasks that use shell commands.
|
# For tasks that use shell commands.
|
||||||
gem "shell", "~> 0.8.1"
|
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.
|
# For automated tests.
|
||||||
group :development, :test do
|
gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test]
|
||||||
gem "rspec-rails", "~> 7.0"
|
gem "webmock", "~> 3.24", group: [:test]
|
||||||
end
|
|
||||||
group :test do
|
|
||||||
gem "webmock", "~> 3.24"
|
|
||||||
end
|
|
||||||
|
|
|
||||||
264
Gemfile.lock
264
Gemfile.lock
|
|
@ -6,31 +6,31 @@ PATH
|
||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
action_text-trix (2.1.15)
|
action_text-trix (2.1.16)
|
||||||
railties
|
railties
|
||||||
actioncable (8.1.1)
|
actioncable (8.1.2)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (8.1.1)
|
actionmailbox (8.1.2)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.2)
|
||||||
activejob (= 8.1.1)
|
activejob (= 8.1.2)
|
||||||
activerecord (= 8.1.1)
|
activerecord (= 8.1.2)
|
||||||
activestorage (= 8.1.1)
|
activestorage (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (8.1.1)
|
actionmailer (8.1.2)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.2)
|
||||||
actionview (= 8.1.1)
|
actionview (= 8.1.2)
|
||||||
activejob (= 8.1.1)
|
activejob (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (8.1.1)
|
actionpack (8.1.2)
|
||||||
actionview (= 8.1.1)
|
actionview (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
|
|
@ -38,36 +38,36 @@ GEM
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.1.1)
|
actiontext (8.1.2)
|
||||||
action_text-trix (~> 2.1.15)
|
action_text-trix (~> 2.1.15)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.2)
|
||||||
activerecord (= 8.1.1)
|
activerecord (= 8.1.2)
|
||||||
activestorage (= 8.1.1)
|
activestorage (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (8.1.1)
|
actionview (8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
activejob (8.1.1)
|
activejob (8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (8.1.1)
|
activemodel (8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
activerecord (8.1.1)
|
activerecord (8.1.2)
|
||||||
activemodel (= 8.1.1)
|
activemodel (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activestorage (8.1.1)
|
activestorage (8.1.2)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.2)
|
||||||
activejob (= 8.1.1)
|
activejob (= 8.1.2)
|
||||||
activerecord (= 8.1.1)
|
activerecord (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.1.1)
|
activesupport (8.1.2)
|
||||||
base64
|
base64
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||||
|
|
@ -83,14 +83,13 @@ GEM
|
||||||
addressable (2.8.8)
|
addressable (2.8.8)
|
||||||
public_suffix (>= 2.0.2, < 8.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
aes_key_wrap (1.1.0)
|
aes_key_wrap (1.1.0)
|
||||||
ast (2.4.3)
|
async (2.35.3)
|
||||||
async (2.35.0)
|
|
||||||
console (~> 1.29)
|
console (~> 1.29)
|
||||||
fiber-annotation
|
fiber-annotation
|
||||||
io-event (~> 1.11)
|
io-event (~> 1.11)
|
||||||
metrics (~> 0.12)
|
metrics (~> 0.12)
|
||||||
traces (~> 0.18)
|
traces (~> 0.18)
|
||||||
async-container (0.27.7)
|
async-container (0.29.0)
|
||||||
async (~> 2.22)
|
async (~> 2.22)
|
||||||
async-http (0.89.0)
|
async-http (0.89.0)
|
||||||
async (>= 2.10.2)
|
async (>= 2.10.2)
|
||||||
|
|
@ -106,19 +105,17 @@ GEM
|
||||||
async-http (~> 0.56)
|
async-http (~> 0.56)
|
||||||
async-pool (0.11.1)
|
async-pool (0.11.1)
|
||||||
async (>= 2.0)
|
async (>= 2.0)
|
||||||
async-service (0.16.0)
|
async-service (0.17.0)
|
||||||
async
|
async
|
||||||
async-container (~> 0.16)
|
async-container (~> 0.28)
|
||||||
string-format (~> 0.2)
|
string-format (~> 0.2)
|
||||||
attr_required (1.0.2)
|
attr_required (1.0.2)
|
||||||
backport (1.2.0)
|
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.21)
|
||||||
benchmark (0.5.0)
|
|
||||||
bigdecimal (4.0.1)
|
bigdecimal (4.0.1)
|
||||||
bindata (2.5.1)
|
bindata (2.5.1)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.20.1)
|
bootsnap (1.21.1)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
childprocess (5.1.0)
|
childprocess (5.1.0)
|
||||||
|
|
@ -146,10 +143,7 @@ GEM
|
||||||
devise-encryptable (0.2.0)
|
devise-encryptable (0.2.0)
|
||||||
devise (>= 2.1.0)
|
devise (>= 2.1.0)
|
||||||
diff-lcs (1.6.2)
|
diff-lcs (1.6.2)
|
||||||
dotenv (2.8.1)
|
dotenv (3.2.0)
|
||||||
dotenv-rails (2.8.1)
|
|
||||||
dotenv (= 2.8.1)
|
|
||||||
railties (>= 3.2)
|
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
e2mmap (0.1.0)
|
e2mmap (0.1.0)
|
||||||
email_validator (2.2.4)
|
email_validator (2.2.4)
|
||||||
|
|
@ -174,21 +168,20 @@ GEM
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
faraday-follow_redirects (0.4.0)
|
faraday-follow_redirects (0.5.0)
|
||||||
faraday (>= 1, < 3)
|
faraday (>= 1, < 3)
|
||||||
faraday-net_http (3.4.2)
|
faraday-net_http (3.4.2)
|
||||||
net-http (~> 0.5)
|
net-http (~> 0.5)
|
||||||
ffi (1.17.2)
|
ffi (1.17.3-aarch64-linux-gnu)
|
||||||
ffi (1.17.2-aarch64-linux-gnu)
|
ffi (1.17.3-arm64-darwin)
|
||||||
ffi (1.17.2-arm64-darwin)
|
ffi (1.17.3-x86_64-linux-gnu)
|
||||||
ffi (1.17.2-x86_64-linux-gnu)
|
|
||||||
fiber-annotation (0.2.0)
|
fiber-annotation (0.2.0)
|
||||||
fiber-local (1.1.0)
|
fiber-local (1.1.0)
|
||||||
fiber-storage
|
fiber-storage
|
||||||
fiber-storage (1.0.1)
|
fiber-storage (1.0.1)
|
||||||
globalid (1.3.0)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
haml (6.4.0)
|
haml (7.2.0)
|
||||||
temple (>= 0.8.2)
|
temple (>= 0.8.2)
|
||||||
thor
|
thor
|
||||||
tilt
|
tilt
|
||||||
|
|
@ -206,7 +199,6 @@ GEM
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
jaro_winkler (1.6.1)
|
|
||||||
jsbundling-rails (1.3.1)
|
jsbundling-rails (1.3.1)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
json (2.18.0)
|
json (2.18.0)
|
||||||
|
|
@ -217,19 +209,13 @@ GEM
|
||||||
bindata
|
bindata
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday-follow_redirects
|
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)
|
launchy (3.1.1)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
childprocess (~> 5.0)
|
childprocess (~> 5.0)
|
||||||
logger (~> 1.6)
|
logger (~> 1.6)
|
||||||
letter_opener (1.10.0)
|
letter_opener (1.10.0)
|
||||||
launchy (>= 2.2, < 4)
|
launchy (>= 2.2, < 4)
|
||||||
lint_roller (1.1.0)
|
localhost (1.7.0)
|
||||||
localhost (1.6.0)
|
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
loofah (2.25.0)
|
loofah (2.25.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
|
|
@ -245,7 +231,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,21 +248,18 @@ 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)
|
nokogiri (1.19.0-aarch64-linux-gnu)
|
||||||
mini_portile2 (~> 2.8.2)
|
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.10-aarch64-linux-gnu)
|
nokogiri (1.19.0-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.10-arm64-darwin)
|
nokogiri (1.19.0-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
|
||||||
nokogiri (1.18.10-x86_64-linux-gnu)
|
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
omniauth (2.1.4)
|
omniauth (2.1.4)
|
||||||
hashie (>= 3.4.6)
|
hashie (>= 3.4.6)
|
||||||
logger
|
logger
|
||||||
rack (>= 2.2.3)
|
rack (>= 2.2.3)
|
||||||
rack-protection
|
rack-protection
|
||||||
omniauth-rails_csrf_protection (1.0.2)
|
omniauth-rails_csrf_protection (2.0.1)
|
||||||
actionpack (>= 4.2)
|
actionpack (>= 4.2)
|
||||||
omniauth (~> 2.0)
|
omniauth (~> 2.0)
|
||||||
omniauth_openid_connect (0.7.1)
|
omniauth_openid_connect (0.7.1)
|
||||||
|
|
@ -298,38 +280,34 @@ GEM
|
||||||
webfinger (~> 2.0)
|
webfinger (~> 2.0)
|
||||||
openssl (3.3.2)
|
openssl (3.3.2)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
parallel (1.27.0)
|
|
||||||
parser (3.3.10.0)
|
|
||||||
ast (~> 2.4.1)
|
|
||||||
racc
|
|
||||||
pp (0.6.3)
|
pp (0.6.3)
|
||||||
prettyprint
|
prettyprint
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.7.0)
|
prism (1.8.0)
|
||||||
process-metrics (0.8.0)
|
process-metrics (0.8.0)
|
||||||
console (~> 1.8)
|
console (~> 1.8)
|
||||||
json (~> 2)
|
json (~> 2)
|
||||||
samovar (~> 2.1)
|
samovar (~> 2.1)
|
||||||
protocol-hpack (1.5.1)
|
protocol-hpack (1.5.1)
|
||||||
protocol-http (0.56.1)
|
protocol-http (0.58.0)
|
||||||
protocol-http1 (0.35.2)
|
protocol-http1 (0.36.0)
|
||||||
protocol-http (~> 0.22)
|
protocol-http (~> 0.58)
|
||||||
protocol-http2 (0.23.0)
|
protocol-http2 (0.24.0)
|
||||||
protocol-hpack (~> 1.4)
|
protocol-hpack (~> 1.4)
|
||||||
protocol-http (~> 0.47)
|
protocol-http (~> 0.47)
|
||||||
protocol-rack (0.19.0)
|
protocol-rack (0.21.0)
|
||||||
io-stream (>= 0.10)
|
io-stream (>= 0.10)
|
||||||
protocol-http (~> 0.43)
|
protocol-http (~> 0.58)
|
||||||
rack (>= 1.0)
|
rack (>= 1.0)
|
||||||
psych (5.3.1)
|
psych (5.3.1)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (7.0.0)
|
public_suffix (7.0.2)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.4)
|
rack (3.2.4)
|
||||||
rack-attack (6.8.0)
|
rack-attack (6.8.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-mini-profiler (3.3.1)
|
rack-mini-profiler (4.0.1)
|
||||||
rack (>= 1.2.0)
|
rack (>= 1.2.0)
|
||||||
rack-oauth2 (2.3.0)
|
rack-oauth2 (2.3.0)
|
||||||
activesupport
|
activesupport
|
||||||
|
|
@ -349,20 +327,20 @@ GEM
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.3.1)
|
rackup (2.3.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
rails (8.1.1)
|
rails (8.1.2)
|
||||||
actioncable (= 8.1.1)
|
actioncable (= 8.1.2)
|
||||||
actionmailbox (= 8.1.1)
|
actionmailbox (= 8.1.2)
|
||||||
actionmailer (= 8.1.1)
|
actionmailer (= 8.1.2)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.2)
|
||||||
actiontext (= 8.1.1)
|
actiontext (= 8.1.2)
|
||||||
actionview (= 8.1.1)
|
actionview (= 8.1.2)
|
||||||
activejob (= 8.1.1)
|
activejob (= 8.1.2)
|
||||||
activemodel (= 8.1.1)
|
activemodel (= 8.1.2)
|
||||||
activerecord (= 8.1.1)
|
activerecord (= 8.1.2)
|
||||||
activestorage (= 8.1.1)
|
activestorage (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.1.1)
|
railties (= 8.1.2)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
|
|
@ -373,31 +351,26 @@ GEM
|
||||||
rails-i18n (8.1.0)
|
rails-i18n (8.1.0)
|
||||||
i18n (>= 0.7, < 2)
|
i18n (>= 0.7, < 2)
|
||||||
railties (>= 8.0.0, < 9)
|
railties (>= 8.0.0, < 9)
|
||||||
railties (8.1.1)
|
railties (8.1.2)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.2)
|
||||||
activesupport (= 8.1.1)
|
activesupport (= 8.1.2)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
thor (~> 1.0, >= 1.2.2)
|
thor (~> 1.0, >= 1.2.2)
|
||||||
tsort (>= 0.2)
|
tsort (>= 0.2)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
|
||||||
rake (13.3.1)
|
rake (13.3.1)
|
||||||
rbs (2.8.4)
|
|
||||||
rdiscount (2.2.7.3)
|
rdiscount (2.2.7.3)
|
||||||
rdoc (7.0.3)
|
rdoc (7.1.0)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
regexp_parser (2.11.3)
|
|
||||||
reline (0.6.3)
|
reline (0.6.3)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
responders (3.2.0)
|
responders (3.2.0)
|
||||||
actionpack (>= 7.0)
|
actionpack (>= 7.0)
|
||||||
railties (>= 7.0)
|
railties (>= 7.0)
|
||||||
reverse_markdown (2.1.1)
|
|
||||||
nokogiri
|
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rspec-core (3.13.6)
|
rspec-core (3.13.6)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
|
|
@ -407,36 +380,24 @@ GEM
|
||||||
rspec-mocks (3.13.7)
|
rspec-mocks (3.13.7)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-rails (7.1.1)
|
rspec-rails (8.0.2)
|
||||||
actionpack (>= 7.0)
|
actionpack (>= 7.2)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.2)
|
||||||
railties (>= 7.0)
|
railties (>= 7.2)
|
||||||
rspec-core (~> 3.13)
|
rspec-core (~> 3.13)
|
||||||
rspec-expectations (~> 3.13)
|
rspec-expectations (~> 3.13)
|
||||||
rspec-mocks (~> 3.13)
|
rspec-mocks (~> 3.13)
|
||||||
rspec-support (~> 3.13)
|
rspec-support (~> 3.13)
|
||||||
rspec-support (3.13.6)
|
rspec-support (3.13.6)
|
||||||
rubocop (1.82.1)
|
ruby-vips (2.3.0)
|
||||||
json (~> 2.3)
|
ffi (~> 1.12)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
logger
|
||||||
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)
|
samovar (2.4.1)
|
||||||
console (~> 1.0)
|
console (~> 1.0)
|
||||||
mapping (~> 1.0)
|
mapping (~> 1.0)
|
||||||
sanitize (6.1.3)
|
sanitize (7.0.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.16.8)
|
||||||
sass-rails (6.0.0)
|
sass-rails (6.0.0)
|
||||||
sassc-rails (~> 2.1, >= 2.1.1)
|
sassc-rails (~> 2.1, >= 2.1.1)
|
||||||
sassc (2.4.0)
|
sassc (2.4.0)
|
||||||
|
|
@ -457,25 +418,6 @@ GEM
|
||||||
shell (0.8.1)
|
shell (0.8.1)
|
||||||
e2mmap
|
e2mmap
|
||||||
sync
|
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)
|
sprockets (4.2.2)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
logger
|
logger
|
||||||
|
|
@ -496,20 +438,17 @@ GEM
|
||||||
temple (0.10.4)
|
temple (0.10.4)
|
||||||
terser (1.2.6)
|
terser (1.2.6)
|
||||||
execjs (>= 0.3.0, < 3)
|
execjs (>= 0.3.0, < 3)
|
||||||
thor (1.4.0)
|
thor (1.5.0)
|
||||||
thread-local (1.1.0)
|
thread-local (1.1.0)
|
||||||
tilt (2.6.1)
|
tilt (2.7.0)
|
||||||
timeout (0.6.0)
|
timeout (0.6.0)
|
||||||
traces (0.18.2)
|
traces (0.18.2)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
turbo-rails (2.0.20)
|
turbo-rails (2.0.21)
|
||||||
actionpack (>= 7.1.0)
|
actionpack (>= 7.1.0)
|
||||||
railties (>= 7.1.0)
|
railties (>= 7.1.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
unicode-display_width (3.2.0)
|
|
||||||
unicode-emoji (~> 4.1)
|
|
||||||
unicode-emoji (4.2.0)
|
|
||||||
uri (1.1.1)
|
uri (1.1.1)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
validate_url (1.0.15)
|
validate_url (1.0.15)
|
||||||
|
|
@ -535,13 +474,11 @@ GEM
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
will_paginate (4.0.1)
|
will_paginate (4.0.1)
|
||||||
yard (0.9.38)
|
|
||||||
zeitwerk (2.7.4)
|
zeitwerk (2.7.4)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux
|
aarch64-linux
|
||||||
arm64-darwin
|
arm64-darwin
|
||||||
ruby
|
|
||||||
x86_64-linux
|
x86_64-linux
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
|
|
@ -553,9 +490,9 @@ DEPENDENCIES
|
||||||
debug (~> 1.9.2)
|
debug (~> 1.9.2)
|
||||||
devise (~> 4.9, >= 4.9.2)
|
devise (~> 4.9, >= 4.9.2)
|
||||||
devise-encryptable (~> 0.2.0)
|
devise-encryptable (~> 0.2.0)
|
||||||
dotenv-rails (~> 2.8, >= 2.8.1)
|
dotenv (~> 3.2)
|
||||||
falcon (~> 0.48.0)
|
falcon (~> 0.48.0)
|
||||||
haml (~> 6.1, >= 6.1.1)
|
haml (~> 7.2)
|
||||||
http_accept_language (~> 2.1, >= 2.1.1)
|
http_accept_language (~> 2.1, >= 2.1.1)
|
||||||
jsbundling-rails (~> 1.3)
|
jsbundling-rails (~> 1.3)
|
||||||
letter_opener (~> 1.8, >= 1.8.1)
|
letter_opener (~> 1.8, >= 1.8.1)
|
||||||
|
|
@ -563,21 +500,20 @@ DEPENDENCIES
|
||||||
mysql2 (~> 0.5.5)
|
mysql2 (~> 0.5.5)
|
||||||
nokogiri (~> 1.15, >= 1.15.3)
|
nokogiri (~> 1.15, >= 1.15.3)
|
||||||
omniauth (~> 2.1)
|
omniauth (~> 2.1)
|
||||||
omniauth-rails_csrf_protection (~> 1.0)
|
omniauth-rails_csrf_protection (~> 2.0, >= 2.0.1)
|
||||||
omniauth_openid_connect (~> 0.7.1)
|
omniauth_openid_connect (~> 0.7.1)
|
||||||
rack-attack (~> 6.7)
|
rack-attack (~> 6.7)
|
||||||
rack-mini-profiler (~> 3.1)
|
rack-mini-profiler (~> 4.0, >= 4.0.1)
|
||||||
rails (~> 8.0, >= 8.0.1)
|
rails (~> 8.0, >= 8.0.1)
|
||||||
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 (~> 8.0, >= 8.0.2)
|
||||||
sanitize (~> 6.0, >= 6.0.2)
|
ruby-vips (~> 2.2)
|
||||||
|
sanitize (~> 7.0)
|
||||||
sass-rails (~> 6.0)
|
sass-rails (~> 6.0)
|
||||||
sentry-rails (~> 5.12)
|
sentry-rails (~> 5.12)
|
||||||
sentry-ruby (~> 5.12)
|
sentry-ruby (~> 5.12)
|
||||||
shell (~> 0.8.1)
|
shell (~> 0.8.1)
|
||||||
solargraph (~> 0.50.0)
|
|
||||||
solargraph-rails (~> 1.1)
|
|
||||||
sprockets (~> 4.2)
|
sprockets (~> 4.2)
|
||||||
stackprof (~> 0.2.25)
|
stackprof (~> 0.2.25)
|
||||||
terser (~> 1.1, >= 1.1.17)
|
terser (~> 1.1, >= 1.1.17)
|
||||||
|
|
|
||||||
|
|
@ -150,13 +150,13 @@ function updateStage() {
|
||||||
|
|
||||||
function updateCanvasDimensions() {
|
function updateCanvasDimensions() {
|
||||||
// Set the canvas's internal dimensions to be higher, if the device has high
|
// Set the canvas's internal dimensions to be higher, if the device has high
|
||||||
// DPI. Scale the movie clip to match, too.
|
// DPI. Scale the stage to match, too.
|
||||||
const internalWidth = canvas.offsetWidth * window.devicePixelRatio;
|
const internalWidth = canvas.offsetWidth * window.devicePixelRatio;
|
||||||
const internalHeight = canvas.offsetHeight * window.devicePixelRatio;
|
const internalHeight = canvas.offsetHeight * window.devicePixelRatio;
|
||||||
canvas.width = internalWidth;
|
canvas.width = internalWidth;
|
||||||
canvas.height = internalHeight;
|
canvas.height = internalHeight;
|
||||||
movieClip.scaleX = internalWidth / library.properties.width;
|
stage.scaleX = internalWidth / library.properties.width;
|
||||||
movieClip.scaleY = internalHeight / library.properties.height;
|
stage.scaleY = internalHeight / library.properties.height;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
window.addEventListener("resize", () => {
|
||||||
|
|
@ -176,23 +176,28 @@ window.addEventListener("resize", () => {
|
||||||
////////////////////////////////////////////////////
|
////////////////////////////////////////////////////
|
||||||
|
|
||||||
async function startMovie() {
|
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
|
// Load the movie's library (from the JS file already run), and use it to
|
||||||
// build a movie clip.
|
// build a movie clip.
|
||||||
library = await getLibrary();
|
library = await getLibrary();
|
||||||
movieClip = buildMovieClip(library);
|
movieClip = buildMovieClip(library);
|
||||||
|
|
||||||
updateCanvasDimensions();
|
|
||||||
|
|
||||||
if (canvas.getContext("2d") == null) {
|
if (canvas.getContext("2d") == null) {
|
||||||
console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
|
console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
|
||||||
// TODO: "Too many animations!"
|
// TODO: "Too many animations!"
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
stage = new window.createjs.Stage(canvas);
|
stage = new library.Stage(canvas);
|
||||||
stage.addChild(movieClip);
|
stage.addChild(movieClip);
|
||||||
|
updateCanvasDimensions();
|
||||||
updateStage();
|
updateStage();
|
||||||
|
|
||||||
|
// Signal to the library that the composition is ready.
|
||||||
|
AdobeAn.compositionLoaded(library.properties.id);
|
||||||
|
|
||||||
loadingStatus = "loaded";
|
loadingStatus = "loaded";
|
||||||
canvas.setAttribute("data-status", "loaded");
|
canvas.setAttribute("data-status", "loaded");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,9 @@ class ApplicationController < ActionController::Base
|
||||||
before_action :save_return_to_path,
|
before_action :save_return_to_path,
|
||||||
if: ->(c) { c.controller_name == 'sessions' && c.action_name == 'new' }
|
if: ->(c) { c.controller_name == 'sessions' && c.action_name == 'new' }
|
||||||
|
|
||||||
# Enable profiling tools if logged in as admin.
|
# Enable profiling tools in development or when logged in as an admin.
|
||||||
before_action do
|
before_action do
|
||||||
if current_user && current_user.admin?
|
Rack::MiniProfiler.authorize_request if Rails.env.development? || current_user&.admin?
|
||||||
Rack::MiniProfiler.authorize_request
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
class AccessDenied < StandardError; end
|
class AccessDenied < StandardError; end
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@ class ItemTradesController < ApplicationController
|
||||||
@item = Item.find params[:item_id]
|
@item = Item.find params[:item_id]
|
||||||
@type = type_from_params
|
@type = type_from_params
|
||||||
|
|
||||||
@item_trades = @item.closet_hangers.trading.includes(:user, :list).
|
@item_trades = @item.visible_trades(
|
||||||
user_is_active.order('users.last_trade_activity_at DESC').
|
scope: ClosetHanger.includes(:user, :list).
|
||||||
to_trades(current_user, request.remote_ip)
|
order('users.last_trade_activity_at DESC'),
|
||||||
|
user: current_user,
|
||||||
|
remote_ip: request.remote_ip
|
||||||
|
)
|
||||||
@trades = @item_trades[@type]
|
@trades = @item_trades[@type]
|
||||||
|
|
||||||
if user_signed_in?
|
if user_signed_in?
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,10 @@ class ItemsController < ApplicationController
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html do
|
format.html do
|
||||||
@trades = @item.closet_hangers.trading.user_is_active.
|
@trades = @item.visible_trades(
|
||||||
to_trades(current_user, request.remote_ip)
|
user: current_user,
|
||||||
|
remote_ip: request.remote_ip
|
||||||
|
)
|
||||||
|
|
||||||
@contributors_with_counts = @item.contributors_with_counts
|
@contributors_with_counts = @item.contributors_with_counts
|
||||||
|
|
||||||
|
|
@ -107,6 +109,15 @@ class ItemsController < ApplicationController
|
||||||
includes(:species).merge(Species.alphabetical)
|
includes(:species).merge(Species.alphabetical)
|
||||||
end
|
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
|
format.gif do
|
||||||
expires_in 1.month
|
expires_in 1.month
|
||||||
redirect_to @item.thumbnail_url, allow_other_host: true
|
redirect_to @item.thumbnail_url, allow_other_host: true
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -118,6 +137,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])
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,13 @@ module ItemsHelper
|
||||||
AUCTION_GENIE_URL_TEMPLATE.expand(auctiongenie: item.name).to_s
|
AUCTION_GENIE_URL_TEMPLATE.expand(auctiongenie: item.name).to_s
|
||||||
end
|
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)
|
def format_contribution_count(count)
|
||||||
" (×#{count})".html_safe if count > 1
|
" (×#{count})".html_safe if count > 1
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -390,6 +390,10 @@ export function loadMovieLibrary(librarySrc, { preferArchive = false } = {}) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
delete window.AdobeAn.compositions[compositionId];
|
delete window.AdobeAn.compositions[compositionId];
|
||||||
|
|
||||||
|
// Install the MotionGuidePlugin, which is needed for motion path animations.
|
||||||
|
window.createjs.MotionGuidePlugin.install();
|
||||||
|
|
||||||
const library = composition.getLibrary();
|
const library = composition.getLibrary();
|
||||||
|
|
||||||
// One more loading step as part of loading this library is loading the
|
// One more loading step as part of loading this library is loading the
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class Item < ApplicationRecord
|
||||||
attr_writer :current_body_id, :owned, :wanted
|
attr_writer :current_body_id, :owned, :wanted
|
||||||
|
|
||||||
NCRarities = [0, 500]
|
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, -> {
|
scope :newest, -> {
|
||||||
order(arel_table[:created_at].desc) if arel_table[:created_at]
|
order(arel_table[:created_at].desc) if arel_table[:created_at]
|
||||||
|
|
@ -162,7 +162,7 @@ class Item < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def pb?
|
def pb?
|
||||||
I18n.with_locale(:en) { self.description == PAINTBRUSH_SET_DESCRIPTION }
|
I18n.with_locale(:en) { self.description.include?(PAINTBRUSH_SET_DESCRIPTION) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def np?
|
def np?
|
||||||
|
|
@ -444,11 +444,34 @@ class Item < ApplicationRecord
|
||||||
created_at || Time.new(2010)
|
created_at || Time.new(2010)
|
||||||
end
|
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={})
|
def as_json(options={})
|
||||||
super({
|
result = super({
|
||||||
only: [:id, :name, :description, :thumbnail_url, :rarity_index],
|
only: [:id, :name, :description, :thumbnail_url, :rarity_index],
|
||||||
methods: [:zones_restrict],
|
methods: [:zones_restrict],
|
||||||
}.merge(options))
|
}.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
|
end
|
||||||
|
|
||||||
def compatible_body_ids(use_cached: true)
|
def compatible_body_ids(use_cached: true)
|
||||||
|
|
|
||||||
|
|
@ -172,52 +172,67 @@ class Outfit < ApplicationRecord
|
||||||
def visible_layers
|
def visible_layers
|
||||||
return [] if pet_state.nil?
|
return [] if pet_state.nil?
|
||||||
|
|
||||||
# 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
|
||||||
|
|
@ -234,18 +249,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,
|
||||||
|
|
@ -254,6 +271,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
|
||||||
|
|
|
||||||
63
app/models/pet/auto_modeling.rb
Normal file
63
app/models/pet/auto_modeling.rb
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# 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
|
||||||
|
|
@ -120,6 +120,44 @@ module Neopets::NCMall
|
||||||
end
|
end
|
||||||
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
|
private
|
||||||
|
|
||||||
# Map load_type from menu JSON to the v2 API type parameter.
|
# Map load_type from menu JSON to the v2 API type parameter.
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,13 @@
|
||||||
= link_to t('items.show.resources.jn_items'), jn_items_url_for(item)
|
= 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)
|
= link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item)
|
||||||
- if item.nc_trade_value
|
- if item.nc_trade_value
|
||||||
= link_to 'https://www.neopets.com/~lebron',
|
= link_to lebron_url_for(item),
|
||||||
title: nc_trade_value_updated_at_text(item.nc_trade_value) do
|
title: nc_trade_value_updated_at_text(item.nc_trade_value) do
|
||||||
= t 'items.show.resources.lebron',
|
= t 'items.show.resources.lebron_value',
|
||||||
value: nc_trade_value_estimate_text(item.nc_trade_value)
|
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?
|
- unless item.nc?
|
||||||
= link_to t('items.show.resources.shop_wizard'), shop_wizard_url_for(item)
|
= 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)
|
= link_to t('items.show.resources.trading_post'), trading_post_url_for(item)
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
#!/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")
|
|
||||||
|
|
@ -310,7 +310,8 @@ en:
|
||||||
resources:
|
resources:
|
||||||
jn_items: JN Items
|
jn_items: JN Items
|
||||||
impress_2020: DTI 2020
|
impress_2020: DTI 2020
|
||||||
lebron: "Lebron: %{value}"
|
lebron: Lebron
|
||||||
|
lebron_value: "Lebron: %{value}"
|
||||||
shop_wizard: Shop Wizard
|
shop_wizard: Shop Wizard
|
||||||
trading_post: Trades
|
trading_post: Trades
|
||||||
auction_genie: Auctions
|
auction_genie: Auctions
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,28 @@ This crowdsourced approach is why DTI is "self-sustaining" - users passively con
|
||||||
|
|
||||||
See `app/models/pet/modeling_snapshot.rb` for the full implementation.
|
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, 2–5min.
|
||||||
|
|
||||||
### Cached Fields
|
### Cached Fields
|
||||||
|
|
||||||
To avoid expensive queries, several models cache computed data:
|
To avoid expensive queries, several models cache computed data:
|
||||||
|
|
|
||||||
82
lib/outfit_image_renderer.rb
Normal file
82
lib/outfit_image_renderer.rb
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
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
|
||||||
|
|
@ -30,24 +30,17 @@ module RocketAMFExtensions
|
||||||
raise RocketAMF::AMFError.new(first_message_data)
|
raise RocketAMF::AMFError.new(first_message_data)
|
||||||
end
|
end
|
||||||
|
|
||||||
# HACK: It seems to me that these messages come back with Windows-1250
|
# HACK: Older items in Neopets' database have Windows-1250 encoding,
|
||||||
# (or similar) encoding on the strings? I'm basing this on the
|
# while newer items use proper UTF-8. We detect which encoding was used
|
||||||
# Patchwork Staff item, whose description arrives as:
|
# by checking if the string is valid UTF-8, and only re-encode if needed.
|
||||||
#
|
#
|
||||||
# "That staff is cute, but dont use it as a walking stick \x96 I " +
|
# Example of Windows-1250 item: Patchwork Staff (57311), whose
|
||||||
# "dont think it will hold you up!"
|
# description contains byte 0x96 (en-dash in Windows-1250).
|
||||||
#
|
#
|
||||||
# And the `\x96` is meant to represent an endash, which it doesn't in
|
# Example of UTF-8 item: Carnival Party Décor (80042), whose name
|
||||||
# UTF-8 or in most extended ASCII encodings, but *does* in Windows's
|
# contains proper UTF-8 bytes [195, 169] for the é character.
|
||||||
# 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|
|
result.messages[0].data.body.tap do |body|
|
||||||
reencode_strings! body, "Windows-1250", "UTF-8"
|
reencode_strings_if_needed! body, "Windows-1250", "UTF-8"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -92,13 +85,17 @@ module RocketAMFExtensions
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def reencode_strings!(target, from, to)
|
def reencode_strings_if_needed!(target, from, to)
|
||||||
if target.is_a? String
|
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)
|
target.force_encoding(from).encode!(to)
|
||||||
|
end
|
||||||
elsif target.is_a? Array
|
elsif target.is_a? Array
|
||||||
target.each { |x| reencode_strings!(x, from, to) }
|
target.each { |x| reencode_strings_if_needed!(x, from, to) }
|
||||||
elsif target.is_a? Hash
|
elsif target.is_a? Hash
|
||||||
target.values.each { |x| reencode_strings!(x, from, to) }
|
target.values.each { |x| reencode_strings_if_needed!(x, from, to) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,90 @@ namespace :items do
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,30 @@
|
||||||
namespace :pets do
|
namespace :pets do
|
||||||
desc "Load a pet's viewer data"
|
desc "Load a pet's viewer data (by name or by color/species/items)"
|
||||||
task :load, [:name] => [:environment] do |task, args|
|
task :load, [:first] => [:environment] do |task, args|
|
||||||
viewer_data = Neopets::CustomPets.fetch_viewer_data(args[:name])
|
# 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
|
||||||
|
|
||||||
puts JSON.pretty_generate(viewer_data)
|
puts JSON.pretty_generate(viewer_data)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
BIN
spec/fixtures/outfit_images/Blue Acara With Cape.png
vendored
Normal file
BIN
spec/fixtures/outfit_images/Blue Acara With Cape.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
spec/fixtures/outfit_images/Blue Acara With Hat.png
vendored
Normal file
BIN
spec/fixtures/outfit_images/Blue Acara With Hat.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
spec/fixtures/outfit_images/Blue Acara.png
vendored
Normal file
BIN
spec/fixtures/outfit_images/Blue Acara.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
BIN
spec/fixtures/outfit_images/Cape.png
vendored
Normal file
BIN
spec/fixtures/outfit_images/Cape.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
spec/fixtures/outfit_images/Hat.png
vendored
Normal file
BIN
spec/fixtures/outfit_images/Hat.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
54
spec/fixtures/zones.yml
vendored
54
spec/fixtures/zones.yml
vendored
|
|
@ -28,9 +28,9 @@ hindbiology:
|
||||||
type_id: 1
|
type_id: 1
|
||||||
label: Hind Biology
|
label: Hind Biology
|
||||||
plain_label: hindbiology
|
plain_label: hindbiology
|
||||||
markings:
|
markings1:
|
||||||
id: 31
|
id: 6
|
||||||
depth: 35
|
depth: 8
|
||||||
type_id: 2
|
type_id: 2
|
||||||
label: Markings
|
label: Markings
|
||||||
plain_label: markings
|
plain_label: markings
|
||||||
|
|
@ -88,6 +88,12 @@ body:
|
||||||
type_id: 1
|
type_id: 1
|
||||||
label: Body
|
label: Body
|
||||||
plain_label: body
|
plain_label: body
|
||||||
|
markings2:
|
||||||
|
id: 16
|
||||||
|
depth: 19
|
||||||
|
type_id: 2
|
||||||
|
label: Markings
|
||||||
|
plain_label: markings
|
||||||
bodydisease:
|
bodydisease:
|
||||||
id: 17
|
id: 17
|
||||||
depth: 20
|
depth: 20
|
||||||
|
|
@ -172,6 +178,12 @@ head:
|
||||||
type_id: 1
|
type_id: 1
|
||||||
label: Head
|
label: Head
|
||||||
plain_label: head
|
plain_label: head
|
||||||
|
markings3:
|
||||||
|
id: 31
|
||||||
|
depth: 35
|
||||||
|
type_id: 2
|
||||||
|
label: Markings
|
||||||
|
plain_label: markings
|
||||||
headdisease:
|
headdisease:
|
||||||
id: 32
|
id: 32
|
||||||
depth: 36
|
depth: 36
|
||||||
|
|
@ -196,9 +208,9 @@ glasses:
|
||||||
type_id: 2
|
type_id: 2
|
||||||
label: Glasses
|
label: Glasses
|
||||||
plain_label: glasses
|
plain_label: glasses
|
||||||
earrings:
|
earrings1:
|
||||||
id: 41
|
id: 36
|
||||||
depth: 45
|
depth: 39
|
||||||
type_id: 2
|
type_id: 2
|
||||||
label: Earrings
|
label: Earrings
|
||||||
plain_label: earrings
|
plain_label: earrings
|
||||||
|
|
@ -220,15 +232,21 @@ headdrippings:
|
||||||
type_id: 1
|
type_id: 1
|
||||||
label: Head Drippings
|
label: Head Drippings
|
||||||
plain_label: headdrippings
|
plain_label: headdrippings
|
||||||
hat:
|
hat1:
|
||||||
id: 50
|
id: 40
|
||||||
depth: 16
|
depth: 44
|
||||||
type_id: 2
|
type_id: 2
|
||||||
label: Hat
|
label: Hat
|
||||||
plain_label: hat
|
plain_label: hat
|
||||||
righthanditem:
|
earrings2:
|
||||||
id: 49
|
id: 41
|
||||||
depth: 5
|
depth: 45
|
||||||
|
type_id: 2
|
||||||
|
label: Earrings
|
||||||
|
plain_label: earrings
|
||||||
|
righthanditem1:
|
||||||
|
id: 42
|
||||||
|
depth: 46
|
||||||
type_id: 2
|
type_id: 2
|
||||||
label: Right-hand Item
|
label: Right-hand Item
|
||||||
plain_label: righthanditem
|
plain_label: righthanditem
|
||||||
|
|
@ -268,6 +286,18 @@ backgrounditem:
|
||||||
type_id: 3
|
type_id: 3
|
||||||
label: Background Item
|
label: Background Item
|
||||||
plain_label: backgrounditem
|
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:
|
belt:
|
||||||
id: 51
|
id: 51
|
||||||
depth: 27
|
depth: 27
|
||||||
|
|
|
||||||
240
spec/lib/outfit_image_renderer_spec.rb
Normal file
240
spec/lib/outfit_image_renderer_spec.rb
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
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
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
require_relative '../rails_helper'
|
require_relative '../rails_helper'
|
||||||
|
|
||||||
RSpec.describe Outfit do
|
RSpec.describe Outfit do
|
||||||
fixtures :colors, :species, :zones
|
fixtures :zones, :colors, :species
|
||||||
|
|
||||||
let(:blue) { colors(:blue) }
|
let(:blue) { colors(:blue) }
|
||||||
let(:acara) { species(:acara) }
|
let(:acara) { species(:acara) }
|
||||||
|
|
@ -54,7 +54,7 @@ RSpec.describe Outfit do
|
||||||
|
|
||||||
describe "Item::Appearance#compatible_with?" do
|
describe "Item::Appearance#compatible_with?" do
|
||||||
it "returns true for items in different zones with no restrictions" do
|
it "returns true for items in different zones with no restrictions" do
|
||||||
hat = create_item("Hat", zones(:hat))
|
hat = create_item("Hat", zones(:hat1))
|
||||||
shirt = create_item("Shirt", zones(:shirtdress))
|
shirt = create_item("Shirt", zones(:shirtdress))
|
||||||
|
|
||||||
appearances = Item.appearances_for([hat, shirt], @pet_type)
|
appearances = Item.appearances_for([hat, shirt], @pet_type)
|
||||||
|
|
@ -66,8 +66,8 @@ RSpec.describe Outfit do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "returns false for items in the same zone" do
|
it "returns false for items in the same zone" do
|
||||||
hat1 = create_item("Hat 1", zones(:hat))
|
hat1 = create_item("Hat 1", zones(:hat1))
|
||||||
hat2 = create_item("Hat 2", zones(:hat))
|
hat2 = create_item("Hat 2", zones(:hat1))
|
||||||
|
|
||||||
appearances = Item.appearances_for([hat1, hat2], @pet_type)
|
appearances = Item.appearances_for([hat1, hat2], @pet_type)
|
||||||
hat1_appearance = appearances[hat1.id]
|
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_array[28] = "1" # Set bit for zone 29
|
||||||
zones_restrict = zones_restrict_array.join
|
zones_restrict = zones_restrict_array.join
|
||||||
|
|
||||||
restricting_hat = create_item("Restricting Hat", zones(:hat), zones_restrict: zones_restrict)
|
restricting_hat = create_item("Restricting Hat", zones(:hat1), zones_restrict: zones_restrict)
|
||||||
|
|
||||||
# Create an item in the ruff zone
|
# Create an item in the ruff zone
|
||||||
ruff_item = create_item("Ruff Item", zones(:ruff))
|
ruff_item = create_item("Ruff Item", zones(:ruff))
|
||||||
|
|
@ -104,7 +104,7 @@ RSpec.describe Outfit do
|
||||||
|
|
||||||
it "returns true for empty appearances" do
|
it "returns true for empty appearances" do
|
||||||
# Create items that don't fit the current pet (wrong body_id)
|
# Create items that don't fit the current pet (wrong body_id)
|
||||||
hat = create_item("Hat", zones(:hat), body_id: 999)
|
hat = create_item("Hat", zones(:hat1), body_id: 999)
|
||||||
shirt = create_item("Shirt", zones(:shirtdress), body_id: 999)
|
shirt = create_item("Shirt", zones(:shirtdress), body_id: 999)
|
||||||
|
|
||||||
appearances = Item.appearances_for([hat, shirt], @pet_type)
|
appearances = Item.appearances_for([hat, shirt], @pet_type)
|
||||||
|
|
@ -122,7 +122,7 @@ RSpec.describe Outfit do
|
||||||
|
|
||||||
describe "#without_item" do
|
describe "#without_item" do
|
||||||
it "returns a new outfit without the given item" do
|
it "returns a new outfit without the given item" do
|
||||||
hat = create_item("Hat", zones(:hat))
|
hat = create_item("Hat", zones(:hat1))
|
||||||
outfit_with_hat = @outfit.with_item(hat)
|
outfit_with_hat = @outfit.with_item(hat)
|
||||||
|
|
||||||
new_outfit = outfit_with_hat.without_item(hat)
|
new_outfit = outfit_with_hat.without_item(hat)
|
||||||
|
|
@ -132,7 +132,7 @@ RSpec.describe Outfit do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "returns a new outfit instance (immutable)" do
|
it "returns a new outfit instance (immutable)" do
|
||||||
hat = create_item("Hat", zones(:hat))
|
hat = create_item("Hat", zones(:hat1))
|
||||||
outfit_with_hat = @outfit.with_item(hat)
|
outfit_with_hat = @outfit.with_item(hat)
|
||||||
|
|
||||||
new_outfit = outfit_with_hat.without_item(hat)
|
new_outfit = outfit_with_hat.without_item(hat)
|
||||||
|
|
@ -142,7 +142,7 @@ RSpec.describe Outfit do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "does nothing if the item is not worn" do
|
it "does nothing if the item is not worn" do
|
||||||
hat = create_item("Hat", zones(:hat))
|
hat = create_item("Hat", zones(:hat1))
|
||||||
|
|
||||||
new_outfit = @outfit.without_item(hat)
|
new_outfit = @outfit.without_item(hat)
|
||||||
|
|
||||||
|
|
@ -152,7 +152,7 @@ RSpec.describe Outfit do
|
||||||
|
|
||||||
describe "#with_item" do
|
describe "#with_item" do
|
||||||
it "adds an item when there are no conflicts" do
|
it "adds an item when there are no conflicts" do
|
||||||
hat = create_item("Hat", zones(:hat))
|
hat = create_item("Hat", zones(:hat1))
|
||||||
|
|
||||||
new_outfit = @outfit.with_item(hat)
|
new_outfit = @outfit.with_item(hat)
|
||||||
|
|
||||||
|
|
@ -160,7 +160,7 @@ RSpec.describe Outfit do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "returns a new outfit instance (immutable)" do
|
it "returns a new outfit instance (immutable)" do
|
||||||
hat = create_item("Hat", zones(:hat))
|
hat = create_item("Hat", zones(:hat1))
|
||||||
|
|
||||||
new_outfit = @outfit.with_item(hat)
|
new_outfit = @outfit.with_item(hat)
|
||||||
|
|
||||||
|
|
@ -170,7 +170,7 @@ RSpec.describe Outfit do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "is idempotent (adding same item twice has no effect)" do
|
it "is idempotent (adding same item twice has no effect)" do
|
||||||
hat = create_item("Hat", zones(:hat))
|
hat = create_item("Hat", zones(:hat1))
|
||||||
|
|
||||||
outfit1 = @outfit.with_item(hat)
|
outfit1 = @outfit.with_item(hat)
|
||||||
outfit2 = outfit1.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
|
it "does not add items that don't fit this pet" do
|
||||||
# Create item with wrong body_id
|
# Create item with wrong body_id
|
||||||
hat = create_item("Hat", zones(:hat), body_id: 999)
|
hat = create_item("Hat", zones(:hat1), body_id: 999)
|
||||||
|
|
||||||
new_outfit = @outfit.with_item(hat)
|
new_outfit = @outfit.with_item(hat)
|
||||||
|
|
||||||
|
|
@ -191,8 +191,8 @@ RSpec.describe Outfit do
|
||||||
|
|
||||||
context "with conflicting items" do
|
context "with conflicting items" do
|
||||||
it "moves conflicting item to closet when items occupy the same zone" do
|
it "moves conflicting item to closet when items occupy the same zone" do
|
||||||
hat1 = create_item("Hat 1", zones(:hat))
|
hat1 = create_item("Hat 1", zones(:hat1))
|
||||||
hat2 = create_item("Hat 2", zones(:hat))
|
hat2 = create_item("Hat 2", zones(:hat1))
|
||||||
|
|
||||||
outfit_with_hat1 = @outfit.with_item(hat1)
|
outfit_with_hat1 = @outfit.with_item(hat1)
|
||||||
outfit_with_hat2 = outfit_with_hat1.with_item(hat2)
|
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 = Array.new(52, "0")
|
||||||
zones_restrict_array[28] = "1"
|
zones_restrict_array[28] = "1"
|
||||||
zones_restrict = zones_restrict_array.join
|
zones_restrict = zones_restrict_array.join
|
||||||
restricting_hat = create_item("Restricting Hat", zones(:hat), zones_restrict: zones_restrict)
|
restricting_hat = create_item("Restricting Hat", zones(:hat1), zones_restrict: zones_restrict)
|
||||||
|
|
||||||
# First wear ruff item, then wear restricting hat
|
# First wear ruff item, then wear restricting hat
|
||||||
outfit_with_ruff = @outfit.with_item(ruff_item)
|
outfit_with_ruff = @outfit.with_item(ruff_item)
|
||||||
|
|
@ -223,7 +223,7 @@ RSpec.describe Outfit do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "keeps compatible items when adding new item" do
|
it "keeps compatible items when adding new item" do
|
||||||
hat = create_item("Hat", zones(:hat))
|
hat = create_item("Hat", zones(:hat1))
|
||||||
shirt = create_item("Shirt", zones(:shirtdress))
|
shirt = create_item("Shirt", zones(:shirtdress))
|
||||||
pants = create_item("Pants", zones(:trousers))
|
pants = create_item("Pants", zones(:trousers))
|
||||||
|
|
||||||
|
|
@ -235,9 +235,9 @@ RSpec.describe Outfit do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "can move multiple conflicting items to closet" do
|
it "can move multiple conflicting items to closet" do
|
||||||
hat1 = create_item("Hat 1", zones(:hat))
|
hat1 = create_item("Hat 1", zones(:hat1))
|
||||||
hat2 = create_item("Hat 2", zones(:hat))
|
hat2 = create_item("Hat 2", zones(:hat1))
|
||||||
hat3 = create_item("Hat 3", zones(:hat))
|
hat3 = create_item("Hat 3", zones(:hat1))
|
||||||
|
|
||||||
# Wear hat1 and hat2 by manually building the outfit
|
# Wear hat1 and hat2 by manually building the outfit
|
||||||
# (normally you can't, but we're testing the conflict resolution)
|
# (normally you can't, but we're testing the conflict resolution)
|
||||||
|
|
@ -253,8 +253,8 @@ RSpec.describe Outfit do
|
||||||
end
|
end
|
||||||
|
|
||||||
it "does not duplicate items in closet if already closeted" do
|
it "does not duplicate items in closet if already closeted" do
|
||||||
hat1 = create_item("Hat 1", zones(:hat))
|
hat1 = create_item("Hat 1", zones(:hat1))
|
||||||
hat2 = create_item("Hat 2", zones(:hat))
|
hat2 = create_item("Hat 2", zones(:hat1))
|
||||||
|
|
||||||
# Wear hat1
|
# Wear hat1
|
||||||
outfit1 = @outfit.with_item(hat1)
|
outfit1 = @outfit.with_item(hat1)
|
||||||
|
|
@ -278,11 +278,552 @@ RSpec.describe Outfit do
|
||||||
it "works with outfit that has no pet_state" do
|
it "works with outfit that has no pet_state" do
|
||||||
# This shouldn't happen in practice, but let's be defensive
|
# This shouldn't happen in practice, but let's be defensive
|
||||||
outfit_no_pet = Outfit.new
|
outfit_no_pet = Outfit.new
|
||||||
hat = create_item("Hat", zones(:hat))
|
hat = create_item("Hat", zones(:hat1))
|
||||||
|
|
||||||
# Should not crash, but also won't add the item
|
# Should not crash, but also won't add the item
|
||||||
expect { outfit_no_pet.with_item(hat) }.not_to raise_error
|
expect { outfit_no_pet.with_item(hat) }.not_to raise_error
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
92
spec/models/pet/auto_modeling_spec.rb
Normal file
92
spec/models/pet/auto_modeling_spec.rb
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
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
|
||||||
|
|
@ -3,9 +3,14 @@ module Neopets::CustomPets
|
||||||
DATA_DIR = Pathname.new(__dir__) / "custom_pets"
|
DATA_DIR = Pathname.new(__dir__) / "custom_pets"
|
||||||
|
|
||||||
def self.fetch_viewer_data(pet_name, ...)
|
def self.fetch_viewer_data(pet_name, ...)
|
||||||
File.open(DATA_DIR / "#{pet_name}.json") do |file|
|
# NOTE: Windows doesn't support `@` in filenames, so we use a `scis` directory instead.
|
||||||
HashWithIndifferentAccess.new JSON.load(file)
|
path = if pet_name.start_with?('@')
|
||||||
|
DATA_DIR / "scis" / "#{pet_name[1..]}.json"
|
||||||
|
else
|
||||||
|
DATA_DIR / "#{pet_name}.json"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
File.open(path) { |f| HashWithIndifferentAccess.new JSON.load(f) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.fetch_metadata(...)
|
def self.fetch_metadata(...)
|
||||||
|
|
|
||||||
69
spec/support/mocks/custom_pets/scis/purpchia-39552.json
Normal file
69
spec/support/mocks/custom_pets/scis/purpchia-39552.json
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
{
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
85
spec/support/mocks/custom_pets/scis/purpchia-71706.json
Normal file
85
spec/support/mocks/custom_pets/scis/purpchia-71706.json
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
spec/support/mocks/custom_pets/scis/purpchia-99999.json
Normal file
24
spec/support/mocks/custom_pets/scis/purpchia-99999.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
8
spec/support/mocks/nc_mall.rb
Normal file
8
spec/support/mocks/nc_mall.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# 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
BIN
vendor/cache/action_text-trix-2.1.15.gem
vendored
Binary file not shown.
BIN
vendor/cache/action_text-trix-2.1.16.gem
vendored
Normal file
BIN
vendor/cache/action_text-trix-2.1.16.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/actioncable-8.1.1.gem
vendored
BIN
vendor/cache/actioncable-8.1.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/actioncable-8.1.2.gem
vendored
Normal file
BIN
vendor/cache/actioncable-8.1.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/actionmailbox-8.1.1.gem
vendored
BIN
vendor/cache/actionmailbox-8.1.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/actionmailbox-8.1.2.gem
vendored
Normal file
BIN
vendor/cache/actionmailbox-8.1.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/actionmailer-8.1.1.gem
vendored
BIN
vendor/cache/actionmailer-8.1.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/actionmailer-8.1.2.gem
vendored
Normal file
BIN
vendor/cache/actionmailer-8.1.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/actionpack-8.1.1.gem
vendored
BIN
vendor/cache/actionpack-8.1.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/actionpack-8.1.2.gem
vendored
Normal file
BIN
vendor/cache/actionpack-8.1.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/actiontext-8.1.1.gem
vendored
BIN
vendor/cache/actiontext-8.1.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/actiontext-8.1.2.gem
vendored
Normal file
BIN
vendor/cache/actiontext-8.1.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/actionview-8.1.1.gem
vendored
BIN
vendor/cache/actionview-8.1.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/actionview-8.1.2.gem
vendored
Normal file
BIN
vendor/cache/actionview-8.1.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/activejob-8.1.1.gem
vendored
BIN
vendor/cache/activejob-8.1.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/activejob-8.1.2.gem
vendored
Normal file
BIN
vendor/cache/activejob-8.1.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/activemodel-8.1.1.gem
vendored
BIN
vendor/cache/activemodel-8.1.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/activemodel-8.1.2.gem
vendored
Normal file
BIN
vendor/cache/activemodel-8.1.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/activerecord-8.1.1.gem
vendored
BIN
vendor/cache/activerecord-8.1.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/activerecord-8.1.2.gem
vendored
Normal file
BIN
vendor/cache/activerecord-8.1.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/activestorage-8.1.1.gem
vendored
BIN
vendor/cache/activestorage-8.1.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/activestorage-8.1.2.gem
vendored
Normal file
BIN
vendor/cache/activestorage-8.1.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/activesupport-8.1.1.gem
vendored
BIN
vendor/cache/activesupport-8.1.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/activesupport-8.1.2.gem
vendored
Normal file
BIN
vendor/cache/activesupport-8.1.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/ast-2.4.3.gem
vendored
BIN
vendor/cache/ast-2.4.3.gem
vendored
Binary file not shown.
BIN
vendor/cache/async-2.35.0.gem
vendored
BIN
vendor/cache/async-2.35.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/async-2.35.3.gem
vendored
Normal file
BIN
vendor/cache/async-2.35.3.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/async-container-0.27.7.gem
vendored
BIN
vendor/cache/async-container-0.27.7.gem
vendored
Binary file not shown.
BIN
vendor/cache/async-container-0.29.0.gem
vendored
Normal file
BIN
vendor/cache/async-container-0.29.0.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/async-service-0.16.0.gem
vendored
BIN
vendor/cache/async-service-0.16.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/async-service-0.17.0.gem
vendored
Normal file
BIN
vendor/cache/async-service-0.17.0.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/backport-1.2.0.gem
vendored
BIN
vendor/cache/backport-1.2.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/bcrypt-3.1.20.gem
vendored
BIN
vendor/cache/bcrypt-3.1.20.gem
vendored
Binary file not shown.
BIN
vendor/cache/bcrypt-3.1.21.gem
vendored
Normal file
BIN
vendor/cache/bcrypt-3.1.21.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/benchmark-0.5.0.gem
vendored
BIN
vendor/cache/benchmark-0.5.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/bootsnap-1.20.1.gem
vendored
BIN
vendor/cache/bootsnap-1.20.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/bootsnap-1.21.1.gem
vendored
Normal file
BIN
vendor/cache/bootsnap-1.21.1.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/dotenv-2.8.1.gem
vendored
BIN
vendor/cache/dotenv-2.8.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/dotenv-3.2.0.gem
vendored
Normal file
BIN
vendor/cache/dotenv-3.2.0.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/dotenv-rails-2.8.1.gem
vendored
BIN
vendor/cache/dotenv-rails-2.8.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/faraday-follow_redirects-0.4.0.gem
vendored
BIN
vendor/cache/faraday-follow_redirects-0.4.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/faraday-follow_redirects-0.5.0.gem
vendored
Normal file
BIN
vendor/cache/faraday-follow_redirects-0.5.0.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/ffi-1.17.2-aarch64-linux-gnu.gem
vendored
BIN
vendor/cache/ffi-1.17.2-aarch64-linux-gnu.gem
vendored
Binary file not shown.
BIN
vendor/cache/ffi-1.17.2-arm64-darwin.gem
vendored
BIN
vendor/cache/ffi-1.17.2-arm64-darwin.gem
vendored
Binary file not shown.
BIN
vendor/cache/ffi-1.17.2-x86_64-linux-gnu.gem
vendored
BIN
vendor/cache/ffi-1.17.2-x86_64-linux-gnu.gem
vendored
Binary file not shown.
BIN
vendor/cache/ffi-1.17.2.gem
vendored
BIN
vendor/cache/ffi-1.17.2.gem
vendored
Binary file not shown.
BIN
vendor/cache/ffi-1.17.3-aarch64-linux-gnu.gem
vendored
Normal file
BIN
vendor/cache/ffi-1.17.3-aarch64-linux-gnu.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/ffi-1.17.3-arm64-darwin.gem
vendored
Normal file
BIN
vendor/cache/ffi-1.17.3-arm64-darwin.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/ffi-1.17.3-x86_64-linux-gnu.gem
vendored
Normal file
BIN
vendor/cache/ffi-1.17.3-x86_64-linux-gnu.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/haml-6.4.0.gem
vendored
BIN
vendor/cache/haml-6.4.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/haml-7.2.0.gem
vendored
Normal file
BIN
vendor/cache/haml-7.2.0.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/jaro_winkler-1.6.1.gem
vendored
BIN
vendor/cache/jaro_winkler-1.6.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/kramdown-2.5.1.gem
vendored
BIN
vendor/cache/kramdown-2.5.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/kramdown-parser-gfm-1.1.0.gem
vendored
BIN
vendor/cache/kramdown-parser-gfm-1.1.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/language_server-protocol-3.17.0.5.gem
vendored
BIN
vendor/cache/language_server-protocol-3.17.0.5.gem
vendored
Binary file not shown.
BIN
vendor/cache/lint_roller-1.1.0.gem
vendored
BIN
vendor/cache/lint_roller-1.1.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/localhost-1.6.0.gem
vendored
BIN
vendor/cache/localhost-1.6.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/localhost-1.7.0.gem
vendored
Normal file
BIN
vendor/cache/localhost-1.7.0.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/mini_portile2-2.8.9.gem
vendored
BIN
vendor/cache/mini_portile2-2.8.9.gem
vendored
Binary file not shown.
BIN
vendor/cache/nokogiri-1.18.10-arm64-darwin.gem
vendored
BIN
vendor/cache/nokogiri-1.18.10-arm64-darwin.gem
vendored
Binary file not shown.
BIN
vendor/cache/nokogiri-1.18.10.gem
vendored
BIN
vendor/cache/nokogiri-1.18.10.gem
vendored
Binary file not shown.
BIN
vendor/cache/nokogiri-1.19.0-aarch64-linux-gnu.gem
vendored
Normal file
BIN
vendor/cache/nokogiri-1.19.0-aarch64-linux-gnu.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/nokogiri-1.19.0-arm64-darwin.gem
vendored
Normal file
BIN
vendor/cache/nokogiri-1.19.0-arm64-darwin.gem
vendored
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue