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
|
||||
# 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.
|
||||
gem 'sprockets', '~> 4.2'
|
||||
gem 'haml', '~> 6.1', '>= 6.1.1'
|
||||
gem 'haml', '~> 7.2'
|
||||
gem 'sass-rails', '~> 6.0'
|
||||
gem 'terser', '~> 1.1', '>= 1.1.17'
|
||||
gem 'jsbundling-rails', '~> 1.3'
|
||||
|
|
@ -25,7 +25,7 @@ gem 'turbo-rails', '~> 2.0'
|
|||
gem 'devise', '~> 4.9', '>= 4.9.2'
|
||||
gem 'devise-encryptable', '~> 0.2.0'
|
||||
gem 'omniauth', '~> 2.1'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 2.0', '>= 2.0.1'
|
||||
gem "omniauth_openid_connect", "~> 0.7.1"
|
||||
|
||||
# For pagination UI.
|
||||
|
|
@ -40,7 +40,7 @@ gem 'nokogiri', '~> 1.15', '>= 1.15.3'
|
|||
|
||||
# For safely rendering users' Markdown + HTML on item list pages.
|
||||
gem 'rdiscount', '~> 2.2', '>= 2.2.7.1'
|
||||
gem 'sanitize', '~> 6.0', '>= 6.0.2'
|
||||
gem 'sanitize', '~> 7.0'
|
||||
|
||||
# For working with Neopets APIs.
|
||||
# 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 "thread-local", "~> 1.1", require: false
|
||||
|
||||
# For image processing (outfit PNG rendering).
|
||||
gem "ruby-vips", "~> 2.2"
|
||||
|
||||
# For debugging.
|
||||
group :development do
|
||||
gem 'debug', '~> 1.9.2'
|
||||
|
|
@ -71,7 +74,7 @@ end
|
|||
gem 'bootsnap', '~> 1.16', require: false
|
||||
|
||||
# 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 "stackprof", "~> 0.2.25"
|
||||
|
||||
|
|
@ -82,16 +85,6 @@ gem "sentry-rails", "~> 5.12"
|
|||
# For tasks that use shell commands.
|
||||
gem "shell", "~> 0.8.1"
|
||||
|
||||
# For workspace autocomplete.
|
||||
group :development do
|
||||
gem "solargraph", "~> 0.50.0"
|
||||
gem "solargraph-rails", "~> 1.1"
|
||||
end
|
||||
|
||||
# For automated tests.
|
||||
group :development, :test do
|
||||
gem "rspec-rails", "~> 7.0"
|
||||
end
|
||||
group :test do
|
||||
gem "webmock", "~> 3.24"
|
||||
end
|
||||
gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test]
|
||||
gem "webmock", "~> 3.24", group: [:test]
|
||||
|
|
|
|||
264
Gemfile.lock
264
Gemfile.lock
|
|
@ -6,31 +6,31 @@ PATH
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
action_text-trix (2.1.15)
|
||||
action_text-trix (2.1.16)
|
||||
railties
|
||||
actioncable (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actioncable (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actionmailbox (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actionmailer (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actionpack (8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
|
|
@ -38,36 +38,36 @@ GEM
|
|||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.1)
|
||||
actiontext (8.1.2)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actionpack (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actionview (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
activejob (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
activerecord (8.1.1)
|
||||
activemodel (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
activemodel (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activerecord (8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
activestorage (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.1)
|
||||
activesupport (8.1.2)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
|
|
@ -83,14 +83,13 @@ GEM
|
|||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
ast (2.4.3)
|
||||
async (2.35.0)
|
||||
async (2.35.3)
|
||||
console (~> 1.29)
|
||||
fiber-annotation
|
||||
io-event (~> 1.11)
|
||||
metrics (~> 0.12)
|
||||
traces (~> 0.18)
|
||||
async-container (0.27.7)
|
||||
async-container (0.29.0)
|
||||
async (~> 2.22)
|
||||
async-http (0.89.0)
|
||||
async (>= 2.10.2)
|
||||
|
|
@ -106,19 +105,17 @@ GEM
|
|||
async-http (~> 0.56)
|
||||
async-pool (0.11.1)
|
||||
async (>= 2.0)
|
||||
async-service (0.16.0)
|
||||
async-service (0.17.0)
|
||||
async
|
||||
async-container (~> 0.16)
|
||||
async-container (~> 0.28)
|
||||
string-format (~> 0.2)
|
||||
attr_required (1.0.2)
|
||||
backport (1.2.0)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.5.0)
|
||||
bcrypt (3.1.21)
|
||||
bigdecimal (4.0.1)
|
||||
bindata (2.5.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.20.1)
|
||||
bootsnap (1.21.1)
|
||||
msgpack (~> 1.2)
|
||||
builder (3.3.0)
|
||||
childprocess (5.1.0)
|
||||
|
|
@ -146,10 +143,7 @@ GEM
|
|||
devise-encryptable (0.2.0)
|
||||
devise (>= 2.1.0)
|
||||
diff-lcs (1.6.2)
|
||||
dotenv (2.8.1)
|
||||
dotenv-rails (2.8.1)
|
||||
dotenv (= 2.8.1)
|
||||
railties (>= 3.2)
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
e2mmap (0.1.0)
|
||||
email_validator (2.2.4)
|
||||
|
|
@ -174,21 +168,20 @@ GEM
|
|||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.4.0)
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-net_http (3.4.2)
|
||||
net-http (~> 0.5)
|
||||
ffi (1.17.2)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-arm64-darwin)
|
||||
ffi (1.17.2-x86_64-linux-gnu)
|
||||
ffi (1.17.3-aarch64-linux-gnu)
|
||||
ffi (1.17.3-arm64-darwin)
|
||||
ffi (1.17.3-x86_64-linux-gnu)
|
||||
fiber-annotation (0.2.0)
|
||||
fiber-local (1.1.0)
|
||||
fiber-storage
|
||||
fiber-storage (1.0.1)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
haml (6.4.0)
|
||||
haml (7.2.0)
|
||||
temple (>= 0.8.2)
|
||||
thor
|
||||
tilt
|
||||
|
|
@ -206,7 +199,6 @@ GEM
|
|||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jaro_winkler (1.6.1)
|
||||
jsbundling-rails (1.3.1)
|
||||
railties (>= 6.0.0)
|
||||
json (2.18.0)
|
||||
|
|
@ -217,19 +209,13 @@ GEM
|
|||
bindata
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
kramdown (2.5.1)
|
||||
rexml (>= 3.3.9)
|
||||
kramdown-parser-gfm (1.1.0)
|
||||
kramdown (~> 2.0)
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.1.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
lint_roller (1.1.0)
|
||||
localhost (1.6.0)
|
||||
localhost (1.7.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.25.0)
|
||||
crass (~> 1.0.2)
|
||||
|
|
@ -245,7 +231,6 @@ GEM
|
|||
memory_profiler (1.1.0)
|
||||
metrics (0.15.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (6.0.1)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
|
|
@ -263,21 +248,18 @@ GEM
|
|||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.18.10)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
nokogiri (1.19.0-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-aarch64-linux-gnu)
|
||||
nokogiri (1.19.0-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-x86_64-linux-gnu)
|
||||
nokogiri (1.19.0-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
omniauth (2.1.4)
|
||||
hashie (>= 3.4.6)
|
||||
logger
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-rails_csrf_protection (1.0.2)
|
||||
omniauth-rails_csrf_protection (2.0.1)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
omniauth_openid_connect (0.7.1)
|
||||
|
|
@ -298,38 +280,34 @@ GEM
|
|||
webfinger (~> 2.0)
|
||||
openssl (3.3.2)
|
||||
orm_adapter (0.5.0)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.10.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.7.0)
|
||||
prism (1.8.0)
|
||||
process-metrics (0.8.0)
|
||||
console (~> 1.8)
|
||||
json (~> 2)
|
||||
samovar (~> 2.1)
|
||||
protocol-hpack (1.5.1)
|
||||
protocol-http (0.56.1)
|
||||
protocol-http1 (0.35.2)
|
||||
protocol-http (~> 0.22)
|
||||
protocol-http2 (0.23.0)
|
||||
protocol-http (0.58.0)
|
||||
protocol-http1 (0.36.0)
|
||||
protocol-http (~> 0.58)
|
||||
protocol-http2 (0.24.0)
|
||||
protocol-hpack (~> 1.4)
|
||||
protocol-http (~> 0.47)
|
||||
protocol-rack (0.19.0)
|
||||
protocol-rack (0.21.0)
|
||||
io-stream (>= 0.10)
|
||||
protocol-http (~> 0.43)
|
||||
protocol-http (~> 0.58)
|
||||
rack (>= 1.0)
|
||||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.0)
|
||||
public_suffix (7.0.2)
|
||||
racc (1.8.1)
|
||||
rack (3.2.4)
|
||||
rack-attack (6.8.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-mini-profiler (3.3.1)
|
||||
rack-mini-profiler (4.0.1)
|
||||
rack (>= 1.2.0)
|
||||
rack-oauth2 (2.3.0)
|
||||
activesupport
|
||||
|
|
@ -349,20 +327,20 @@ GEM
|
|||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (8.1.1)
|
||||
actioncable (= 8.1.1)
|
||||
actionmailbox (= 8.1.1)
|
||||
actionmailer (= 8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
actiontext (= 8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activemodel (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
rails (8.1.2)
|
||||
actioncable (= 8.1.2)
|
||||
actionmailbox (= 8.1.2)
|
||||
actionmailer (= 8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actiontext (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.1)
|
||||
railties (= 8.1.2)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
|
|
@ -373,31 +351,26 @@ GEM
|
|||
rails-i18n (8.1.0)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 8.0.0, < 9)
|
||||
railties (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
railties (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
rbs (2.8.4)
|
||||
rdiscount (2.2.7.3)
|
||||
rdoc (7.0.3)
|
||||
rdoc (7.1.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
responders (3.2.0)
|
||||
actionpack (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
reverse_markdown (2.1.1)
|
||||
nokogiri
|
||||
rexml (3.4.4)
|
||||
rspec-core (3.13.6)
|
||||
rspec-support (~> 3.13.0)
|
||||
|
|
@ -407,36 +380,24 @@ GEM
|
|||
rspec-mocks (3.13.7)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (7.1.1)
|
||||
actionpack (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rspec-rails (8.0.2)
|
||||
actionpack (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.6)
|
||||
rubocop (1.82.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.48.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.48.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.3.0)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
samovar (2.4.1)
|
||||
console (~> 1.0)
|
||||
mapping (~> 1.0)
|
||||
sanitize (6.1.3)
|
||||
sanitize (7.0.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
nokogiri (>= 1.16.8)
|
||||
sass-rails (6.0.0)
|
||||
sassc-rails (~> 2.1, >= 2.1.1)
|
||||
sassc (2.4.0)
|
||||
|
|
@ -457,25 +418,6 @@ GEM
|
|||
shell (0.8.1)
|
||||
e2mmap
|
||||
sync
|
||||
solargraph (0.50.0)
|
||||
backport (~> 1.2)
|
||||
benchmark
|
||||
bundler (~> 2.0)
|
||||
diff-lcs (~> 1.4)
|
||||
e2mmap
|
||||
jaro_winkler (~> 1.5)
|
||||
kramdown (~> 2.3)
|
||||
kramdown-parser-gfm (~> 1.1)
|
||||
parser (~> 3.0)
|
||||
rbs (~> 2.0)
|
||||
reverse_markdown (~> 2.0)
|
||||
rubocop (~> 1.38)
|
||||
thor (~> 1.0)
|
||||
tilt (~> 2.0)
|
||||
yard (~> 0.9, >= 0.9.24)
|
||||
solargraph-rails (1.2.4)
|
||||
activesupport
|
||||
solargraph (>= 0.48.0, <= 0.57)
|
||||
sprockets (4.2.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
logger
|
||||
|
|
@ -496,20 +438,17 @@ GEM
|
|||
temple (0.10.4)
|
||||
terser (1.2.6)
|
||||
execjs (>= 0.3.0, < 3)
|
||||
thor (1.4.0)
|
||||
thor (1.5.0)
|
||||
thread-local (1.1.0)
|
||||
tilt (2.6.1)
|
||||
tilt (2.7.0)
|
||||
timeout (0.6.0)
|
||||
traces (0.18.2)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.20)
|
||||
turbo-rails (2.0.21)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.2.0)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
validate_url (1.0.15)
|
||||
|
|
@ -535,13 +474,11 @@ GEM
|
|||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
will_paginate (4.0.1)
|
||||
yard (0.9.38)
|
||||
zeitwerk (2.7.4)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
arm64-darwin
|
||||
ruby
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
|
|
@ -553,9 +490,9 @@ DEPENDENCIES
|
|||
debug (~> 1.9.2)
|
||||
devise (~> 4.9, >= 4.9.2)
|
||||
devise-encryptable (~> 0.2.0)
|
||||
dotenv-rails (~> 2.8, >= 2.8.1)
|
||||
dotenv (~> 3.2)
|
||||
falcon (~> 0.48.0)
|
||||
haml (~> 6.1, >= 6.1.1)
|
||||
haml (~> 7.2)
|
||||
http_accept_language (~> 2.1, >= 2.1.1)
|
||||
jsbundling-rails (~> 1.3)
|
||||
letter_opener (~> 1.8, >= 1.8.1)
|
||||
|
|
@ -563,21 +500,20 @@ DEPENDENCIES
|
|||
mysql2 (~> 0.5.5)
|
||||
nokogiri (~> 1.15, >= 1.15.3)
|
||||
omniauth (~> 2.1)
|
||||
omniauth-rails_csrf_protection (~> 1.0)
|
||||
omniauth-rails_csrf_protection (~> 2.0, >= 2.0.1)
|
||||
omniauth_openid_connect (~> 0.7.1)
|
||||
rack-attack (~> 6.7)
|
||||
rack-mini-profiler (~> 3.1)
|
||||
rack-mini-profiler (~> 4.0, >= 4.0.1)
|
||||
rails (~> 8.0, >= 8.0.1)
|
||||
rails-i18n (~> 8.0, >= 8.0.1)
|
||||
rdiscount (~> 2.2, >= 2.2.7.1)
|
||||
rspec-rails (~> 7.0)
|
||||
sanitize (~> 6.0, >= 6.0.2)
|
||||
rspec-rails (~> 8.0, >= 8.0.2)
|
||||
ruby-vips (~> 2.2)
|
||||
sanitize (~> 7.0)
|
||||
sass-rails (~> 6.0)
|
||||
sentry-rails (~> 5.12)
|
||||
sentry-ruby (~> 5.12)
|
||||
shell (~> 0.8.1)
|
||||
solargraph (~> 0.50.0)
|
||||
solargraph-rails (~> 1.1)
|
||||
sprockets (~> 4.2)
|
||||
stackprof (~> 0.2.25)
|
||||
terser (~> 1.1, >= 1.1.17)
|
||||
|
|
|
|||
|
|
@ -150,13 +150,13 @@ function updateStage() {
|
|||
|
||||
function updateCanvasDimensions() {
|
||||
// Set the canvas's internal dimensions to be higher, if the device has high
|
||||
// DPI. Scale the movie clip to match, too.
|
||||
// DPI. Scale the stage to match, too.
|
||||
const internalWidth = canvas.offsetWidth * window.devicePixelRatio;
|
||||
const internalHeight = canvas.offsetHeight * window.devicePixelRatio;
|
||||
canvas.width = internalWidth;
|
||||
canvas.height = internalHeight;
|
||||
movieClip.scaleX = internalWidth / library.properties.width;
|
||||
movieClip.scaleY = internalHeight / library.properties.height;
|
||||
stage.scaleX = internalWidth / library.properties.width;
|
||||
stage.scaleY = internalHeight / library.properties.height;
|
||||
}
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
|
|
@ -176,23 +176,28 @@ window.addEventListener("resize", () => {
|
|||
////////////////////////////////////////////////////
|
||||
|
||||
async function startMovie() {
|
||||
// Install the MotionGuidePlugin, which is needed for motion path animations.
|
||||
createjs.MotionGuidePlugin.install();
|
||||
|
||||
// Load the movie's library (from the JS file already run), and use it to
|
||||
// build a movie clip.
|
||||
library = await getLibrary();
|
||||
movieClip = buildMovieClip(library);
|
||||
|
||||
updateCanvasDimensions();
|
||||
|
||||
if (canvas.getContext("2d") == null) {
|
||||
console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
|
||||
// TODO: "Too many animations!"
|
||||
return;
|
||||
}
|
||||
|
||||
stage = new window.createjs.Stage(canvas);
|
||||
stage = new library.Stage(canvas);
|
||||
stage.addChild(movieClip);
|
||||
updateCanvasDimensions();
|
||||
updateStage();
|
||||
|
||||
// Signal to the library that the composition is ready.
|
||||
AdobeAn.compositionLoaded(library.properties.id);
|
||||
|
||||
loadingStatus = "loaded";
|
||||
canvas.setAttribute("data-status", "loaded");
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,9 @@ class ApplicationController < ActionController::Base
|
|||
before_action :save_return_to_path,
|
||||
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
|
||||
if current_user && current_user.admin?
|
||||
Rack::MiniProfiler.authorize_request
|
||||
end
|
||||
Rack::MiniProfiler.authorize_request if Rails.env.development? || current_user&.admin?
|
||||
end
|
||||
|
||||
class AccessDenied < StandardError; end
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ class ItemTradesController < ApplicationController
|
|||
@item = Item.find params[:item_id]
|
||||
@type = type_from_params
|
||||
|
||||
@item_trades = @item.closet_hangers.trading.includes(:user, :list).
|
||||
user_is_active.order('users.last_trade_activity_at DESC').
|
||||
to_trades(current_user, request.remote_ip)
|
||||
@item_trades = @item.visible_trades(
|
||||
scope: ClosetHanger.includes(:user, :list).
|
||||
order('users.last_trade_activity_at DESC'),
|
||||
user: current_user,
|
||||
remote_ip: request.remote_ip
|
||||
)
|
||||
@trades = @item_trades[@type]
|
||||
|
||||
if user_signed_in?
|
||||
|
|
|
|||
|
|
@ -80,8 +80,10 @@ class ItemsController < ApplicationController
|
|||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
@trades = @item.closet_hangers.trading.user_is_active.
|
||||
to_trades(current_user, request.remote_ip)
|
||||
@trades = @item.visible_trades(
|
||||
user: current_user,
|
||||
remote_ip: request.remote_ip
|
||||
)
|
||||
|
||||
@contributors_with_counts = @item.contributors_with_counts
|
||||
|
||||
|
|
@ -107,6 +109,15 @@ class ItemsController < ApplicationController
|
|||
includes(:species).merge(Species.alphabetical)
|
||||
end
|
||||
|
||||
format.json do
|
||||
render json: @item.as_json(
|
||||
include_trade_counts: true,
|
||||
include_nc_trade_value: true,
|
||||
current_user: current_user,
|
||||
remote_ip: request.remote_ip
|
||||
)
|
||||
end
|
||||
|
||||
format.gif do
|
||||
expires_in 1.month
|
||||
redirect_to @item.thumbnail_url, allow_other_host: true
|
||||
|
|
|
|||
|
|
@ -13,7 +13,26 @@ class OutfitsController < ApplicationController
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
def index
|
||||
|
|
@ -118,6 +137,40 @@ class OutfitsController < ApplicationController
|
|||
biology: [:species_id, :color_id, :pose, :pet_state_id])
|
||||
end
|
||||
|
||||
def build_outfit_from_wardrobe_params
|
||||
# Load items first
|
||||
worn_item_ids = params[:objects] ? Array(params[:objects]).map(&:to_i) : []
|
||||
closeted_item_ids = params[:closet] ? Array(params[:closet]).map(&:to_i) : []
|
||||
|
||||
worn_items = Item.where(id: worn_item_ids)
|
||||
closeted_items = Item.where(id: closeted_item_ids)
|
||||
|
||||
# Build outfit with biology and items
|
||||
outfit = Outfit.new(
|
||||
worn_items: worn_items,
|
||||
closeted_items: closeted_items,
|
||||
)
|
||||
|
||||
# Set biology from species, color, and pose params
|
||||
if params[:species] && params[:color] && params[:pose]
|
||||
outfit.biology = {
|
||||
species_id: params[:species],
|
||||
color_id: params[:color],
|
||||
pose: params[:pose]
|
||||
}
|
||||
elsif params[:state]
|
||||
# Alternative: use pet_state_id directly
|
||||
outfit.biology = { pet_state_id: params[:state] }
|
||||
end
|
||||
|
||||
# Set alt style if provided
|
||||
if params[:style]
|
||||
outfit.alt_style_id = params[:style].to_i
|
||||
end
|
||||
|
||||
outfit
|
||||
end
|
||||
|
||||
def find_authorized_outfit
|
||||
raise ActiveRecord::RecordNotFound unless user_signed_in?
|
||||
@outfit = current_user.outfits.find(params[:id])
|
||||
|
|
|
|||
|
|
@ -142,6 +142,13 @@ module ItemsHelper
|
|||
AUCTION_GENIE_URL_TEMPLATE.expand(auctiongenie: item.name).to_s
|
||||
end
|
||||
|
||||
LEBRON_URL_TEMPLATE = Addressable::Template.new(
|
||||
"https://stylisher.club/search/{name}"
|
||||
)
|
||||
def lebron_url_for(item)
|
||||
LEBRON_URL_TEMPLATE.expand(name: item.name).to_s
|
||||
end
|
||||
|
||||
def format_contribution_count(count)
|
||||
" (×#{count})".html_safe if count > 1
|
||||
end
|
||||
|
|
|
|||
|
|
@ -390,6 +390,10 @@ export function loadMovieLibrary(librarySrc, { preferArchive = false } = {}) {
|
|||
);
|
||||
}
|
||||
delete window.AdobeAn.compositions[compositionId];
|
||||
|
||||
// Install the MotionGuidePlugin, which is needed for motion path animations.
|
||||
window.createjs.MotionGuidePlugin.install();
|
||||
|
||||
const library = composition.getLibrary();
|
||||
|
||||
// One more loading step as part of loading this library is loading the
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class Item < ApplicationRecord
|
|||
attr_writer :current_body_id, :owned, :wanted
|
||||
|
||||
NCRarities = [0, 500]
|
||||
PAINTBRUSH_SET_DESCRIPTION = 'This item is part of a deluxe paint brush set!'
|
||||
PAINTBRUSH_SET_DESCRIPTION = 'This item is part of a deluxe paint brush set'
|
||||
|
||||
scope :newest, -> {
|
||||
order(arel_table[:created_at].desc) if arel_table[:created_at]
|
||||
|
|
@ -162,7 +162,7 @@ class Item < ApplicationRecord
|
|||
end
|
||||
|
||||
def pb?
|
||||
I18n.with_locale(:en) { self.description == PAINTBRUSH_SET_DESCRIPTION }
|
||||
I18n.with_locale(:en) { self.description.include?(PAINTBRUSH_SET_DESCRIPTION) }
|
||||
end
|
||||
|
||||
def np?
|
||||
|
|
@ -444,11 +444,34 @@ class Item < ApplicationRecord
|
|||
created_at || Time.new(2010)
|
||||
end
|
||||
|
||||
# Returns the visible trades for this item, filtered by user visibility.
|
||||
# Accepts an optional scope to add additional query constraints (e.g., includes, order).
|
||||
def visible_trades(scope: nil, user: nil, remote_ip: nil)
|
||||
base = closet_hangers.trading.user_is_active
|
||||
base = base.merge(scope) if scope
|
||||
base.to_trades(user, remote_ip)
|
||||
end
|
||||
|
||||
def as_json(options={})
|
||||
super({
|
||||
result = super({
|
||||
only: [:id, :name, :description, :thumbnail_url, :rarity_index],
|
||||
methods: [:zones_restrict],
|
||||
}.merge(options))
|
||||
|
||||
if options[:include_trade_counts]
|
||||
trades = visible_trades(
|
||||
user: options[:current_user],
|
||||
remote_ip: options[:remote_ip]
|
||||
)
|
||||
result['num_trades_offering'] = trades[:offering].size
|
||||
result['num_trades_seeking'] = trades[:seeking].size
|
||||
end
|
||||
|
||||
if options[:include_nc_trade_value]
|
||||
result['nc_trade_value'] = nc_trade_value
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def compatible_body_ids(use_cached: true)
|
||||
|
|
|
|||
|
|
@ -172,52 +172,67 @@ class Outfit < ApplicationRecord
|
|||
def visible_layers
|
||||
return [] if pet_state.nil?
|
||||
|
||||
# TODO: This method doesn't currently handle alt styles! If the outfit has
|
||||
# an alt_style, we should use its layers instead of pet_state layers, and
|
||||
# filter items to only those with body_id=0. This isn't needed yet because
|
||||
# this method is only used on item pages, which don't support alt styles.
|
||||
# See useOutfitAppearance.js for the complete logic including alt styles.
|
||||
item_appearances = item_appearances(swf_asset_includes: [:zone])
|
||||
# Step 1: Choose biology layers - use alt style if present, otherwise pet state
|
||||
if alt_style
|
||||
biology_layers = alt_style.swf_assets.includes(:zone).to_a
|
||||
body = alt_style
|
||||
using_alt_style = true
|
||||
else
|
||||
biology_layers = pet_state.swf_assets.includes(:zone).to_a
|
||||
body = pet_type
|
||||
using_alt_style = false
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
item_restricted_zone_ids = item_appearances.
|
||||
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.
|
||||
#
|
||||
# NOTE: Items' restricted layers also affect what items you can wear at
|
||||
# the same time. We don't enforce anything about that here, and
|
||||
# instead assume that the input by this point is valid!
|
||||
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,
|
||||
# it makes body-specific items incompatible. We use this to disallow UCs
|
||||
# from wearing certain body-specific Biology Effects, Statics, etc, while
|
||||
# still allowing non-body-specific items in those zones! (I think this
|
||||
# happens for some Invisible pet stuff, too?)
|
||||
# Rule 3b: When a biology appearance restricts a zone, or when the pet is
|
||||
# Unconverted, it makes body-specific items incompatible. We use this to
|
||||
# disallow UCs from wearing certain body-specific Biology Effects, Statics,
|
||||
# etc, while still allowing non-body-specific items in those zones! (I think
|
||||
# this happens for some Invisible pet stuff, too?)
|
||||
#
|
||||
# TODO: We shouldn't be *hiding* these zones, like we do with items; we
|
||||
# should be doing this way earlier, to prevent the item from even
|
||||
# showing up even in search results!
|
||||
#
|
||||
# NOTE: This can result in both 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
|
||||
# correct, and the item layer should be on top! (Here, we implement
|
||||
# it by placing item layers second in the list, and rely on JS sort
|
||||
# stability, and *then* rely on the UI to respect that ordering when
|
||||
# rendering them by depth. Not great! 😅)
|
||||
#
|
||||
# NOTE: We used to also include the 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
|
||||
# defensive default, even though we weren't aware of any relevant
|
||||
# items. But now we know that actually the "Bruce Brucey B Mouth"
|
||||
# occupies the real Mouth zone, and still should be visible and
|
||||
# above 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
|
||||
# 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? }
|
||||
else
|
||||
item_layers.reject! { |sa| sa.body_specific? &&
|
||||
pet_restricted_zone_ids.include?(sa.zone_id) }
|
||||
biology_restricted_zone_ids.include?(sa.zone_id) }
|
||||
end
|
||||
|
||||
# A pet appearance can also restrict its own zones. The Wraith Uni is an
|
||||
# interesting example: it has a horn, but its zone restrictions hide it!
|
||||
pet_layers.reject! { |sa| pet_restricted_zone_ids.include?(sa.zone_id) }
|
||||
# Rule 3c: A biology appearance can also restrict its own zones. The Wraith
|
||||
# Uni is an interesting example: it has a horn, but its zone restrictions
|
||||
# hide it!
|
||||
biology_layers.reject! { |sa| biology_restricted_zone_ids.include?(sa.zone_id) }
|
||||
|
||||
(pet_layers + item_layers).sort_by(&:depth)
|
||||
# Step 4: Sort by depth and return
|
||||
(biology_layers + item_layers).sort_by(&:depth)
|
||||
end
|
||||
|
||||
def wardrobe_params
|
||||
{
|
||||
params = {
|
||||
name: name,
|
||||
color: color_id,
|
||||
species: species_id,
|
||||
|
|
@ -254,6 +271,8 @@ class Outfit < ApplicationRecord
|
|||
objects: worn_item_ids,
|
||||
closet: closeted_item_ids,
|
||||
}
|
||||
params[:style] = alt_style_id if alt_style_id.present?
|
||||
params
|
||||
end
|
||||
|
||||
def ensure_unique_name
|
||||
|
|
|
|||
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
|
||||
|
||||
# Generate a new image hash for a pet wearing specific items. Takes a base
|
||||
# pet sci (species/color image hash) and optional item IDs, and returns a
|
||||
# response containing the combined image hash in the :newsci field.
|
||||
# Use the returned hash with Neopets::CustomPets.fetch_viewer_data("@#{newsci}")
|
||||
# to get the full appearance data.
|
||||
PET_DATA_URL = "https://ncmall.neopets.com/mall/ajax/petview/getPetData.php"
|
||||
def self.fetch_pet_data_sci(pet_sci, item_ids = [])
|
||||
Sync do
|
||||
params = {"selPetsci" => pet_sci}
|
||||
item_ids.each { |id| params["itemsList[]"] = id.to_s }
|
||||
|
||||
DTIRequests.post(
|
||||
PET_DATA_URL,
|
||||
[["Content-Type", "application/x-www-form-urlencoded"]],
|
||||
params.to_query,
|
||||
) do |response|
|
||||
if response.status != 200
|
||||
raise ResponseNotOK.new(response.status),
|
||||
"expected status 200 but got #{response.status} (#{PET_DATA_URL})"
|
||||
end
|
||||
|
||||
begin
|
||||
data = JSON.parse(response.read)
|
||||
rescue JSON::ParserError
|
||||
raise UnexpectedResponseFormat,
|
||||
"failed to parse pet data response as JSON"
|
||||
end
|
||||
|
||||
unless data["newsci"].is_a?(String) && data["newsci"].present?
|
||||
raise UnexpectedResponseFormat,
|
||||
"missing or invalid field newsci in pet data response"
|
||||
end
|
||||
|
||||
data["newsci"]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Map load_type from menu JSON to the v2 API type parameter.
|
||||
|
|
|
|||
|
|
@ -15,10 +15,13 @@
|
|||
= link_to t('items.show.resources.jn_items'), jn_items_url_for(item)
|
||||
= link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item)
|
||||
- if item.nc_trade_value
|
||||
= link_to 'https://www.neopets.com/~lebron',
|
||||
= link_to lebron_url_for(item),
|
||||
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)
|
||||
- elsif item.nc?
|
||||
= link_to lebron_url_for(item) do
|
||||
= t 'items.show.resources.lebron'
|
||||
- unless item.nc?
|
||||
= link_to t('items.show.resources.shop_wizard'), shop_wizard_url_for(item)
|
||||
= link_to t('items.show.resources.trading_post'), trading_post_url_for(item)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
jn_items: JN Items
|
||||
impress_2020: DTI 2020
|
||||
lebron: "Lebron: %{value}"
|
||||
lebron: Lebron
|
||||
lebron_value: "Lebron: %{value}"
|
||||
shop_wizard: Shop Wizard
|
||||
trading_post: Trades
|
||||
auction_genie: Auctions
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@
|
|||
name:
|
||||
- libmysqlclient-dev
|
||||
- libyaml-dev
|
||||
- libvips-dev
|
||||
|
||||
- name: Create the app folder
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
# HACK: It seems to me that these messages come back with Windows-1250
|
||||
# (or similar) encoding on the strings? I'm basing this on the
|
||||
# Patchwork Staff item, whose description arrives as:
|
||||
# HACK: Older items in Neopets' database have Windows-1250 encoding,
|
||||
# while newer items use proper UTF-8. We detect which encoding was used
|
||||
# by checking if the string is valid UTF-8, and only re-encode if needed.
|
||||
#
|
||||
# "That staff is cute, but dont use it as a walking stick \x96 I " +
|
||||
# "dont think it will hold you up!"
|
||||
# Example of Windows-1250 item: Patchwork Staff (57311), whose
|
||||
# description contains byte 0x96 (en-dash in Windows-1250).
|
||||
#
|
||||
# And the `\x96` is meant to represent an endash, which it doesn't in
|
||||
# UTF-8 or in most extended ASCII encodings, but *does* in Windows's
|
||||
# specific extended ASCII.
|
||||
#
|
||||
# Idk if this is something to do with the AMFPHP spec or how the AMFPHP
|
||||
# server code they use serializes strings (I couldn't find any
|
||||
# reference to it?), or just their internal database encoding being
|
||||
# passed along as-is, or what? But this seems to be the most correct
|
||||
# interpretation I know how to do, so, let's do it!
|
||||
# Example of UTF-8 item: Carnival Party Décor (80042), whose name
|
||||
# contains proper UTF-8 bytes [195, 169] for the é character.
|
||||
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
|
||||
|
||||
|
|
@ -92,13 +85,17 @@ module RocketAMFExtensions
|
|||
end
|
||||
end
|
||||
|
||||
def reencode_strings!(target, from, to)
|
||||
def reencode_strings_if_needed!(target, from, to)
|
||||
if target.is_a? String
|
||||
# Only re-encode if the string is not valid UTF-8
|
||||
# (indicating it's in the old Windows-1250 encoding)
|
||||
unless target.valid_encoding?
|
||||
target.force_encoding(from).encode!(to)
|
||||
end
|
||||
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
|
||||
target.values.each { |x| reencode_strings!(x, from, to) }
|
||||
target.values.each { |x| reencode_strings_if_needed!(x, from, to) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,4 +9,90 @@ namespace :items do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
desc "Auto-model items on missing body types using NC Mall preview API"
|
||||
task :auto_model, [:limit] => :environment do |task, args|
|
||||
limit = (args[:limit] || 100).to_i
|
||||
dry_run = ENV["DRY_RUN"] == "1"
|
||||
auto_hint = ENV["AUTO_HINT"] != "0"
|
||||
|
||||
puts "Auto-modeling up to #{limit} items#{dry_run ? ' (DRY RUN)' : ''}..."
|
||||
puts "Auto-hint: #{auto_hint ? 'enabled' : 'disabled'}"
|
||||
puts
|
||||
|
||||
# Find items that need modeling, newest first
|
||||
items = Item.is_not_modeled.order(created_at: :desc).limit(limit)
|
||||
puts "Found #{items.count} items to process"
|
||||
puts
|
||||
|
||||
items.each_with_index do |item, index|
|
||||
puts "[#{index + 1}/#{items.count}] Item ##{item.id}: #{item.name}"
|
||||
|
||||
missing_body_ids = item.predicted_missing_body_ids
|
||||
if missing_body_ids.empty?
|
||||
puts " ⚠️ No missing body IDs (item may already be fully modeled)"
|
||||
puts
|
||||
next
|
||||
end
|
||||
|
||||
puts " Missing #{missing_body_ids.size} body IDs: #{missing_body_ids.join(', ')}"
|
||||
|
||||
# Track results for this item
|
||||
results = {modeled: 0, not_compatible: 0, not_found: 0}
|
||||
had_transient_error = false
|
||||
|
||||
missing_body_ids.each do |body_id|
|
||||
if dry_run
|
||||
puts " Body #{body_id}: [DRY RUN] would attempt modeling"
|
||||
next
|
||||
end
|
||||
|
||||
begin
|
||||
result = Pet::AutoModeling.model_item_on_body(item, body_id)
|
||||
results[result] += 1
|
||||
|
||||
case result
|
||||
when :modeled
|
||||
puts " Body #{body_id}: ✅ Modeled successfully"
|
||||
when :not_compatible
|
||||
puts " Body #{body_id}: ❌ Not compatible (heuristic over-predicted)"
|
||||
end
|
||||
rescue Pet::AutoModeling::NoPetTypeForBody => e
|
||||
puts " Body #{body_id}: ⚠️ #{e.message}"
|
||||
rescue Neopets::NCMall::ResponseNotOK => e
|
||||
if e.status >= 500
|
||||
puts " Body #{body_id}: ⚠️ Server error (#{e.status}), will retry later"
|
||||
had_transient_error = true
|
||||
else
|
||||
puts " Body #{body_id}: ❌ HTTP error (#{e.status})"
|
||||
Sentry.capture_exception(e)
|
||||
end
|
||||
rescue Neopets::NCMall::UnexpectedResponseFormat => e
|
||||
puts " Body #{body_id}: ❌ Unexpected response format: #{e.message}"
|
||||
Sentry.capture_exception(e)
|
||||
rescue Neopets::CustomPets::DownloadError => e
|
||||
puts " Body #{body_id}: ⚠️ AMF error: #{e.message}"
|
||||
had_transient_error = true
|
||||
end
|
||||
end
|
||||
|
||||
unless dry_run
|
||||
# Set hint if we've addressed all bodies without transient errors.
|
||||
# That way, if the item is not compatible with some bodies, we'll stop
|
||||
# trying to auto-model it.
|
||||
if auto_hint && !had_transient_error
|
||||
item.update!(modeling_status_hint: "done")
|
||||
puts " 📋 Set modeling_status_hint = 'done'"
|
||||
end
|
||||
end
|
||||
|
||||
puts " Summary: #{results[:modeled]} modeled, #{results[:not_compatible]} not compatible, #{results[:not_found]} not found"
|
||||
puts
|
||||
|
||||
# Be nice to Neopets API
|
||||
sleep 0.5 unless dry_run || index == items.count - 1
|
||||
end
|
||||
|
||||
puts "Done!"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,30 @@
|
|||
namespace :pets do
|
||||
desc "Load a pet's viewer data"
|
||||
task :load, [:name] => [:environment] do |task, args|
|
||||
viewer_data = Neopets::CustomPets.fetch_viewer_data(args[:name])
|
||||
desc "Load a pet's viewer data (by name or by color/species/items)"
|
||||
task :load, [:first] => [:environment] do |task, args|
|
||||
# Collect all arguments (first + extras)
|
||||
all_args = [args[:first]] + args.extras
|
||||
|
||||
# If only one argument, treat it as a pet name
|
||||
if all_args.length == 1
|
||||
viewer_data = Neopets::CustomPets.fetch_viewer_data(all_args[0])
|
||||
else
|
||||
# Multiple arguments: color, species, and optional item IDs
|
||||
color_name = all_args[0]
|
||||
species_name = all_args[1]
|
||||
item_ids = all_args[2..]
|
||||
|
||||
# Look up the PetType to use for the preview
|
||||
pet_type = PetType.matching_name(color_name, species_name).first!
|
||||
|
||||
# Convert it to an image hash for direct lookup
|
||||
new_image_hash = Neopets::NCMall.fetch_pet_data_sci(pet_type.image_hash, item_ids)
|
||||
pet_name = '@' + new_image_hash
|
||||
$stderr.puts "Loading pet #{pet_name}"
|
||||
|
||||
# Load the image hash as if it were a pet
|
||||
viewer_data = Neopets::CustomPets.fetch_viewer_data(pet_name)
|
||||
end
|
||||
|
||||
puts JSON.pretty_generate(viewer_data)
|
||||
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
|
||||
label: Hind Biology
|
||||
plain_label: hindbiology
|
||||
markings:
|
||||
id: 31
|
||||
depth: 35
|
||||
markings1:
|
||||
id: 6
|
||||
depth: 8
|
||||
type_id: 2
|
||||
label: Markings
|
||||
plain_label: markings
|
||||
|
|
@ -88,6 +88,12 @@ body:
|
|||
type_id: 1
|
||||
label: Body
|
||||
plain_label: body
|
||||
markings2:
|
||||
id: 16
|
||||
depth: 19
|
||||
type_id: 2
|
||||
label: Markings
|
||||
plain_label: markings
|
||||
bodydisease:
|
||||
id: 17
|
||||
depth: 20
|
||||
|
|
@ -172,6 +178,12 @@ head:
|
|||
type_id: 1
|
||||
label: Head
|
||||
plain_label: head
|
||||
markings3:
|
||||
id: 31
|
||||
depth: 35
|
||||
type_id: 2
|
||||
label: Markings
|
||||
plain_label: markings
|
||||
headdisease:
|
||||
id: 32
|
||||
depth: 36
|
||||
|
|
@ -196,9 +208,9 @@ glasses:
|
|||
type_id: 2
|
||||
label: Glasses
|
||||
plain_label: glasses
|
||||
earrings:
|
||||
id: 41
|
||||
depth: 45
|
||||
earrings1:
|
||||
id: 36
|
||||
depth: 39
|
||||
type_id: 2
|
||||
label: Earrings
|
||||
plain_label: earrings
|
||||
|
|
@ -220,15 +232,21 @@ headdrippings:
|
|||
type_id: 1
|
||||
label: Head Drippings
|
||||
plain_label: headdrippings
|
||||
hat:
|
||||
id: 50
|
||||
depth: 16
|
||||
hat1:
|
||||
id: 40
|
||||
depth: 44
|
||||
type_id: 2
|
||||
label: Hat
|
||||
plain_label: hat
|
||||
righthanditem:
|
||||
id: 49
|
||||
depth: 5
|
||||
earrings2:
|
||||
id: 41
|
||||
depth: 45
|
||||
type_id: 2
|
||||
label: Earrings
|
||||
plain_label: earrings
|
||||
righthanditem1:
|
||||
id: 42
|
||||
depth: 46
|
||||
type_id: 2
|
||||
label: Right-hand Item
|
||||
plain_label: righthanditem
|
||||
|
|
@ -268,6 +286,18 @@ backgrounditem:
|
|||
type_id: 3
|
||||
label: Background Item
|
||||
plain_label: backgrounditem
|
||||
righthanditem2:
|
||||
id: 49
|
||||
depth: 5
|
||||
type_id: 2
|
||||
label: Right-hand Item
|
||||
plain_label: righthanditem
|
||||
hat2:
|
||||
id: 50
|
||||
depth: 16
|
||||
type_id: 2
|
||||
label: Hat
|
||||
plain_label: hat
|
||||
belt:
|
||||
id: 51
|
||||
depth: 27
|
||||
|
|
|
|||
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'
|
||||
|
||||
RSpec.describe Outfit do
|
||||
fixtures :colors, :species, :zones
|
||||
fixtures :zones, :colors, :species
|
||||
|
||||
let(:blue) { colors(:blue) }
|
||||
let(:acara) { species(:acara) }
|
||||
|
|
@ -54,7 +54,7 @@ RSpec.describe Outfit do
|
|||
|
||||
describe "Item::Appearance#compatible_with?" do
|
||||
it "returns true for items in different zones with no restrictions" do
|
||||
hat = create_item("Hat", zones(:hat))
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
shirt = create_item("Shirt", zones(:shirtdress))
|
||||
|
||||
appearances = Item.appearances_for([hat, shirt], @pet_type)
|
||||
|
|
@ -66,8 +66,8 @@ RSpec.describe Outfit do
|
|||
end
|
||||
|
||||
it "returns false for items in the same zone" do
|
||||
hat1 = create_item("Hat 1", zones(:hat))
|
||||
hat2 = create_item("Hat 2", zones(:hat))
|
||||
hat1 = create_item("Hat 1", zones(:hat1))
|
||||
hat2 = create_item("Hat 2", zones(:hat1))
|
||||
|
||||
appearances = Item.appearances_for([hat1, hat2], @pet_type)
|
||||
hat1_appearance = appearances[hat1.id]
|
||||
|
|
@ -89,7 +89,7 @@ RSpec.describe Outfit do
|
|||
zones_restrict_array[28] = "1" # Set bit for zone 29
|
||||
zones_restrict = zones_restrict_array.join
|
||||
|
||||
restricting_hat = create_item("Restricting Hat", zones(:hat), zones_restrict: zones_restrict)
|
||||
restricting_hat = create_item("Restricting Hat", zones(:hat1), zones_restrict: zones_restrict)
|
||||
|
||||
# Create an item in the ruff zone
|
||||
ruff_item = create_item("Ruff Item", zones(:ruff))
|
||||
|
|
@ -104,7 +104,7 @@ RSpec.describe Outfit do
|
|||
|
||||
it "returns true for empty appearances" do
|
||||
# Create items that don't fit the current pet (wrong body_id)
|
||||
hat = create_item("Hat", zones(:hat), body_id: 999)
|
||||
hat = create_item("Hat", zones(:hat1), body_id: 999)
|
||||
shirt = create_item("Shirt", zones(:shirtdress), body_id: 999)
|
||||
|
||||
appearances = Item.appearances_for([hat, shirt], @pet_type)
|
||||
|
|
@ -122,7 +122,7 @@ RSpec.describe Outfit do
|
|||
|
||||
describe "#without_item" do
|
||||
it "returns a new outfit without the given item" do
|
||||
hat = create_item("Hat", zones(:hat))
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
outfit_with_hat = @outfit.with_item(hat)
|
||||
|
||||
new_outfit = outfit_with_hat.without_item(hat)
|
||||
|
|
@ -132,7 +132,7 @@ RSpec.describe Outfit do
|
|||
end
|
||||
|
||||
it "returns a new outfit instance (immutable)" do
|
||||
hat = create_item("Hat", zones(:hat))
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
outfit_with_hat = @outfit.with_item(hat)
|
||||
|
||||
new_outfit = outfit_with_hat.without_item(hat)
|
||||
|
|
@ -142,7 +142,7 @@ RSpec.describe Outfit do
|
|||
end
|
||||
|
||||
it "does nothing if the item is not worn" do
|
||||
hat = create_item("Hat", zones(:hat))
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
|
||||
new_outfit = @outfit.without_item(hat)
|
||||
|
||||
|
|
@ -152,7 +152,7 @@ RSpec.describe Outfit do
|
|||
|
||||
describe "#with_item" do
|
||||
it "adds an item when there are no conflicts" do
|
||||
hat = create_item("Hat", zones(:hat))
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
|
||||
new_outfit = @outfit.with_item(hat)
|
||||
|
||||
|
|
@ -160,7 +160,7 @@ RSpec.describe Outfit do
|
|||
end
|
||||
|
||||
it "returns a new outfit instance (immutable)" do
|
||||
hat = create_item("Hat", zones(:hat))
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
|
||||
new_outfit = @outfit.with_item(hat)
|
||||
|
||||
|
|
@ -170,7 +170,7 @@ RSpec.describe Outfit do
|
|||
end
|
||||
|
||||
it "is idempotent (adding same item twice has no effect)" do
|
||||
hat = create_item("Hat", zones(:hat))
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
|
||||
outfit1 = @outfit.with_item(hat)
|
||||
outfit2 = outfit1.with_item(hat)
|
||||
|
|
@ -182,7 +182,7 @@ RSpec.describe Outfit do
|
|||
|
||||
it "does not add items that don't fit this pet" do
|
||||
# Create item with wrong body_id
|
||||
hat = create_item("Hat", zones(:hat), body_id: 999)
|
||||
hat = create_item("Hat", zones(:hat1), body_id: 999)
|
||||
|
||||
new_outfit = @outfit.with_item(hat)
|
||||
|
||||
|
|
@ -191,8 +191,8 @@ RSpec.describe Outfit do
|
|||
|
||||
context "with conflicting items" do
|
||||
it "moves conflicting item to closet when items occupy the same zone" do
|
||||
hat1 = create_item("Hat 1", zones(:hat))
|
||||
hat2 = create_item("Hat 2", zones(:hat))
|
||||
hat1 = create_item("Hat 1", zones(:hat1))
|
||||
hat2 = create_item("Hat 2", zones(:hat1))
|
||||
|
||||
outfit_with_hat1 = @outfit.with_item(hat1)
|
||||
outfit_with_hat2 = outfit_with_hat1.with_item(hat2)
|
||||
|
|
@ -211,7 +211,7 @@ RSpec.describe Outfit do
|
|||
zones_restrict_array = Array.new(52, "0")
|
||||
zones_restrict_array[28] = "1"
|
||||
zones_restrict = zones_restrict_array.join
|
||||
restricting_hat = create_item("Restricting Hat", zones(: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
|
||||
outfit_with_ruff = @outfit.with_item(ruff_item)
|
||||
|
|
@ -223,7 +223,7 @@ RSpec.describe Outfit do
|
|||
end
|
||||
|
||||
it "keeps compatible items when adding new item" do
|
||||
hat = create_item("Hat", zones(:hat))
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
shirt = create_item("Shirt", zones(:shirtdress))
|
||||
pants = create_item("Pants", zones(:trousers))
|
||||
|
||||
|
|
@ -235,9 +235,9 @@ RSpec.describe Outfit do
|
|||
end
|
||||
|
||||
it "can move multiple conflicting items to closet" do
|
||||
hat1 = create_item("Hat 1", zones(:hat))
|
||||
hat2 = create_item("Hat 2", zones(:hat))
|
||||
hat3 = create_item("Hat 3", zones(:hat))
|
||||
hat1 = create_item("Hat 1", zones(:hat1))
|
||||
hat2 = create_item("Hat 2", zones(:hat1))
|
||||
hat3 = create_item("Hat 3", zones(:hat1))
|
||||
|
||||
# Wear hat1 and hat2 by manually building the outfit
|
||||
# (normally you can't, but we're testing the conflict resolution)
|
||||
|
|
@ -253,8 +253,8 @@ RSpec.describe Outfit do
|
|||
end
|
||||
|
||||
it "does not duplicate items in closet if already closeted" do
|
||||
hat1 = create_item("Hat 1", zones(:hat))
|
||||
hat2 = create_item("Hat 2", zones(:hat))
|
||||
hat1 = create_item("Hat 1", zones(:hat1))
|
||||
hat2 = create_item("Hat 2", zones(:hat1))
|
||||
|
||||
# Wear hat1
|
||||
outfit1 = @outfit.with_item(hat1)
|
||||
|
|
@ -278,11 +278,552 @@ RSpec.describe Outfit do
|
|||
it "works with outfit that has no pet_state" do
|
||||
# This shouldn't happen in practice, but let's be defensive
|
||||
outfit_no_pet = Outfit.new
|
||||
hat = create_item("Hat", zones(:hat))
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
|
||||
# Should not crash, but also won't add the item
|
||||
expect { outfit_no_pet.with_item(hat) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to create a pet state with specific swf_assets
|
||||
def build_pet_state(pet_type, pose: "HAPPY_FEM", swf_assets: [])
|
||||
pet_state = PetState.create!(
|
||||
pet_type: pet_type,
|
||||
pose: pose,
|
||||
swf_asset_ids: swf_assets.empty? ? [0] : swf_assets.map(&:id)
|
||||
)
|
||||
pet_state.swf_assets = swf_assets
|
||||
pet_state
|
||||
end
|
||||
|
||||
# Helper to create a SwfAsset for biology (pet layers)
|
||||
def build_biology_asset(zone, body_id:, zones_restrict: "")
|
||||
@remote_id = (@remote_id || 0) + 1
|
||||
SwfAsset.create!(
|
||||
type: "biology",
|
||||
remote_id: @remote_id,
|
||||
url: "https://images.neopets.example/biology_#{@remote_id}.swf",
|
||||
zone: zone,
|
||||
body_id: body_id,
|
||||
zones_restrict: zones_restrict
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to create a SwfAsset for items (object layers)
|
||||
def build_item_asset(zone, body_id:, zones_restrict: "")
|
||||
@remote_id = (@remote_id || 0) + 1
|
||||
SwfAsset.create!(
|
||||
type: "object",
|
||||
remote_id: @remote_id,
|
||||
url: "https://images.neopets.example/object_#{@remote_id}.swf",
|
||||
zone: zone,
|
||||
body_id: body_id,
|
||||
zones_restrict: zones_restrict
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to create an item with specific swf_assets
|
||||
def build_item(name, swf_assets: [])
|
||||
item = Item.create!(
|
||||
name: name,
|
||||
description: "Test item",
|
||||
thumbnail_url: "https://images.neopets.example/thumbnail.png",
|
||||
rarity: "Common",
|
||||
price: 100,
|
||||
zones_restrict: "",
|
||||
species_support_ids: ""
|
||||
)
|
||||
swf_assets.each do |asset|
|
||||
ParentSwfAssetRelationship.create!(
|
||||
parent: item,
|
||||
swf_asset: asset
|
||||
)
|
||||
end
|
||||
item
|
||||
end
|
||||
|
||||
describe "#visible_layers" do
|
||||
before do
|
||||
# Clean up any existing pet types to avoid conflicts
|
||||
PetType.destroy_all
|
||||
|
||||
# Create a basic pet type for testing
|
||||
@pet_type = PetType.create!(
|
||||
species: species(:acara),
|
||||
color: colors(:blue),
|
||||
body_id: 1,
|
||||
created_at: Time.new(2005)
|
||||
)
|
||||
end
|
||||
|
||||
context "basic layer composition" do
|
||||
it "returns pet layers when no items are worn" do
|
||||
# Create biology assets for the pet
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
body = build_biology_asset(zones(:body), body_id: 1)
|
||||
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head, body])
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
expect(layers).to contain_exactly(head, body)
|
||||
end
|
||||
|
||||
it "returns pet layers and item layers when items are worn" do
|
||||
# Create pet layers
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
body = build_biology_asset(zones(:body), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head, body])
|
||||
|
||||
# Create item layers
|
||||
hat_asset = build_item_asset(zones(:hat1), body_id: 1)
|
||||
hat = build_item("Test Hat", swf_assets: [hat_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [hat]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
expect(layers).to contain_exactly(head, body, hat_asset)
|
||||
end
|
||||
|
||||
it "includes body_id=0 items that fit all pets" do
|
||||
# Create pet layers
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head])
|
||||
|
||||
# Create a background item (body_id=0, fits all)
|
||||
bg_asset = build_item_asset(zones(:background), body_id: 0)
|
||||
background = build_item("Test Background", swf_assets: [bg_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [background]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
expect(layers).to contain_exactly(head, bg_asset)
|
||||
end
|
||||
end
|
||||
|
||||
context "items restricting pet layers (Rule 3a)" do
|
||||
it "hides pet layers in zones that items restrict" do
|
||||
# Create pet layers including hair
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
hair = build_biology_asset(zones(:hairfront), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head, hair])
|
||||
|
||||
# Create a hat that restricts the hair zone
|
||||
# zones_restrict is a bitfield where position 37 (Hair Front zone id) is "1"
|
||||
zones_restrict = "0" * 36 + "1" + "0" * 20 # bit 37 = 1
|
||||
hat_asset = build_item_asset(zones(:hat1), body_id: 1, zones_restrict: zones_restrict)
|
||||
hat = build_item("Hair-hiding Hat", swf_assets: [hat_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [hat]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Hair should be hidden, but head and hat should be visible
|
||||
expect(layers).to contain_exactly(head, hat_asset)
|
||||
expect(layers).not_to include(hair)
|
||||
end
|
||||
|
||||
it "hides multiple pet layers when item restricts multiple zones" do
|
||||
# Create pet layers
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
hair_front = build_biology_asset(zones(:hairfront), body_id: 1)
|
||||
head_transient = build_biology_asset(zones(:headtransientbiology), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head, hair_front, head_transient])
|
||||
|
||||
# Create an item that restricts both Hair Front (37) and Head Transient Biology (38)
|
||||
zones_restrict = "0" * 36 + "11" + "0" * 20 # bits 37 and 38 = 1
|
||||
hood_asset = build_item_asset(zones(:hat1), body_id: 1, zones_restrict: zones_restrict)
|
||||
hood = build_item("Agent Hood", swf_assets: [hood_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [hood]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Both hair_front and head_transient should be hidden
|
||||
expect(layers).to contain_exactly(head, hood_asset)
|
||||
expect(layers).not_to include(hair_front, head_transient)
|
||||
end
|
||||
end
|
||||
|
||||
context "pets restricting body-specific item layers (Rule 3b)" do
|
||||
it "hides body-specific items in zones the pet restricts" do
|
||||
# Create a pet with a layer that restricts the Static zone (46)
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
zones_restrict = "0" * 45 + "1" + "0" * 10 # bit 46 = 1
|
||||
restricting_layer = build_biology_asset(zones(:body), body_id: 1, zones_restrict: zones_restrict)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head, restricting_layer])
|
||||
|
||||
# Create a body-specific Static item
|
||||
static_asset = build_item_asset(zones(:static), body_id: 1)
|
||||
static_item = build_item("Body-specific Static", swf_assets: [static_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [static_item]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# The body-specific static item should be hidden
|
||||
expect(layers).to contain_exactly(head, restricting_layer)
|
||||
expect(layers).not_to include(static_asset)
|
||||
end
|
||||
|
||||
it "allows body_id=0 items even in zones the pet restricts" do
|
||||
# Create a pet with a layer that restricts the Background Item zone (48)
|
||||
# Background Item is type_id 3 (universal zone), so body_id=0 items should always work
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
zones_restrict = "0" * 47 + "1" + "0" * 10 # bit 48 = 1
|
||||
restricting_layer = build_biology_asset(zones(:body), body_id: 1, zones_restrict: zones_restrict)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head, restricting_layer])
|
||||
|
||||
# Create a body_id=0 Background Item (fits all bodies, universal zone)
|
||||
bg_item_asset = build_item_asset(zones(:backgrounditem), body_id: 0)
|
||||
bg_item = build_item("Universal Background Item", swf_assets: [bg_item_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [bg_item]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# The body_id=0 item should be visible even though the zone is restricted
|
||||
expect(layers).to contain_exactly(head, restricting_layer, bg_item_asset)
|
||||
end
|
||||
end
|
||||
|
||||
context "UNCONVERTED pets (Rule 3b special case)" do
|
||||
it "rejects all body-specific items" do
|
||||
# Create an UNCONVERTED pet
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
body = build_biology_asset(zones(:body), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, pose: "UNCONVERTED", swf_assets: [head, body])
|
||||
|
||||
# Create both body-specific and body_id=0 items
|
||||
body_specific_asset = build_item_asset(zones(:hat1), body_id: 1)
|
||||
body_specific_item = build_item("Body-specific Hat", swf_assets: [body_specific_asset])
|
||||
|
||||
universal_asset = build_item_asset(zones(:background), body_id: 0)
|
||||
universal_item = build_item("Universal Background", swf_assets: [universal_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [body_specific_item, universal_item]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Only body_id=0 items should be visible
|
||||
expect(layers).to contain_exactly(head, body, universal_asset)
|
||||
expect(layers).not_to include(body_specific_asset)
|
||||
end
|
||||
|
||||
it "rejects body-specific items regardless of zone restrictions" do
|
||||
# Create an UNCONVERTED pet with no zone restrictions
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, pose: "UNCONVERTED", swf_assets: [head])
|
||||
|
||||
# Create a body-specific item in a zone the pet doesn't restrict
|
||||
hat_asset = build_item_asset(zones(:hat1), body_id: 1)
|
||||
hat = build_item("Body-specific Hat", swf_assets: [hat_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [hat]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# The body-specific item should still be hidden
|
||||
expect(layers).to contain_exactly(head)
|
||||
expect(layers).not_to include(hat_asset)
|
||||
end
|
||||
end
|
||||
|
||||
context "pets restricting their own layers (Rule 3c)" do
|
||||
it "hides pet layers in zones the pet itself restricts" do
|
||||
# Create a pet with a horn asset and a layer that restricts the horn's zone
|
||||
# (Simulating the Wraith Uni case)
|
||||
body = build_biology_asset(zones(:body), body_id: 1)
|
||||
|
||||
# Create a horn in the Head Transient Biology zone (38)
|
||||
horn = build_biology_asset(zones(:headtransientbiology), body_id: 1)
|
||||
|
||||
# Create a layer that restricts zone 38
|
||||
zones_restrict = "0" * 37 + "1" + "0" * 20 # bit 38 = 1
|
||||
restricting_layer = build_biology_asset(zones(:head), body_id: 1, zones_restrict: zones_restrict)
|
||||
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [body, horn, restricting_layer])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# The horn should be hidden by the pet's own restrictions
|
||||
expect(layers).to contain_exactly(body, restricting_layer)
|
||||
expect(layers).not_to include(horn)
|
||||
end
|
||||
|
||||
it "applies self-restrictions in combination with item restrictions" do
|
||||
# Create a pet with multiple layers, some restricted by itself
|
||||
body = build_biology_asset(zones(:body), body_id: 1)
|
||||
hair = build_biology_asset(zones(:hairfront), body_id: 1)
|
||||
|
||||
# Pet restricts its own Head zone (30)
|
||||
zones_restrict = "0" * 29 + "1" + "0" * 25 # bit 30 = 1
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
restricting_layer = build_biology_asset(zones(:eyes), body_id: 1, zones_restrict: zones_restrict)
|
||||
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [body, hair, head, restricting_layer])
|
||||
|
||||
# Add an item that restricts Hair Front (37)
|
||||
item_zones_restrict = "0" * 36 + "1" + "0" * 20 # bit 37 = 1
|
||||
hat_asset = build_item_asset(zones(:hat1), body_id: 1, zones_restrict: item_zones_restrict)
|
||||
hat = build_item("Hair-hiding Hat", swf_assets: [hat_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [hat]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Hair should be hidden by item, Head should be hidden by pet's own restrictions
|
||||
expect(layers).to contain_exactly(body, restricting_layer, hat_asset)
|
||||
expect(layers).not_to include(hair, head)
|
||||
end
|
||||
end
|
||||
|
||||
context "depth sorting and layer ordering" do
|
||||
it "sorts layers by zone depth" do
|
||||
# Create layers in various zones with different depths
|
||||
background = build_biology_asset(zones(:background), body_id: 1) # depth 3
|
||||
body_layer = build_biology_asset(zones(:body), body_id: 1) # depth 18
|
||||
head_layer = build_biology_asset(zones(:head), body_id: 1) # depth 34
|
||||
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head_layer, background, body_layer])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Should be sorted by depth: background (3) < body (18) < head (34)
|
||||
expect(layers[0]).to eq(background)
|
||||
expect(layers[1]).to eq(body_layer)
|
||||
expect(layers[2]).to eq(head_layer)
|
||||
end
|
||||
|
||||
it "places item layers after pet layers at the same depth" do
|
||||
# Create a pet layer and item layer in zones with the same depth
|
||||
# Static zone has depth 48
|
||||
pet_static = build_biology_asset(zones(:static), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [pet_static])
|
||||
|
||||
item_static = build_item_asset(zones(:static), body_id: 0)
|
||||
static_item = build_item("Static Item", swf_assets: [item_static])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [static_item]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Both should be present, with item layer last (on top)
|
||||
expect(layers).to eq([pet_static, item_static])
|
||||
end
|
||||
|
||||
it "sorts complex outfits correctly by depth" do
|
||||
# Create a complex outfit with multiple pet and item layers
|
||||
background = build_biology_asset(zones(:background), body_id: 1) # depth 3
|
||||
body_layer = build_biology_asset(zones(:body), body_id: 1) # depth 18
|
||||
head_layer = build_biology_asset(zones(:head), body_id: 1) # depth 34
|
||||
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head_layer, background, body_layer])
|
||||
|
||||
# Add items at various depths
|
||||
bg_item = build_item_asset(zones(:backgrounditem), body_id: 0) # depth 4
|
||||
hat_asset = build_item_asset(zones(:hat1), body_id: 1) # depth 44
|
||||
shirt_asset = build_item_asset(zones(:shirtdress), body_id: 1) # depth 26
|
||||
|
||||
bg = build_item("Background Item", swf_assets: [bg_item])
|
||||
hat = build_item("Hat", swf_assets: [hat_asset])
|
||||
shirt = build_item("Shirt", swf_assets: [shirt_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
outfit.worn_items = [hat, bg, shirt]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Expected order by depth:
|
||||
# background (3), bg_item (4), body_layer (18), shirt_asset (26),
|
||||
# head_layer (34), hat_asset (44)
|
||||
expect(layers.map(&:depth)).to eq([3, 4, 18, 26, 34, 44])
|
||||
expect(layers).to eq([background, bg_item, body_layer, shirt_asset, head_layer, hat_asset])
|
||||
end
|
||||
end
|
||||
|
||||
context "alt styles (alternative pet appearances)" do
|
||||
before do
|
||||
# Create an alt style with its own body_id distinct from regular pets
|
||||
@alt_style = AltStyle.create!(
|
||||
species: species(:acara),
|
||||
color: colors(:blue),
|
||||
body_id: 999, # Distinct from the regular pet's body_id (1)
|
||||
series_name: "Nostalgic",
|
||||
thumbnail_url: "https://images.neopets.example/alt_style.png"
|
||||
)
|
||||
end
|
||||
|
||||
it "uses alt style layers instead of pet state layers" do
|
||||
# Create regular pet layers
|
||||
regular_head = build_biology_asset(zones(:head), body_id: 1)
|
||||
regular_body = build_biology_asset(zones(:body), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [regular_head, regular_body])
|
||||
|
||||
# Create alt style layers (with the alt style's body_id)
|
||||
alt_head = build_biology_asset(zones(:head), body_id: 999)
|
||||
alt_body = build_biology_asset(zones(:body), body_id: 999)
|
||||
@alt_style.swf_assets = [alt_head, alt_body]
|
||||
|
||||
# Create outfit with alt_style
|
||||
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Should use alt style layers, not pet state layers
|
||||
expect(layers).to contain_exactly(alt_head, alt_body)
|
||||
expect(layers).not_to include(regular_head, regular_body)
|
||||
end
|
||||
|
||||
it "only includes body_id=0 items with alt styles" do
|
||||
# Create alt style layers
|
||||
alt_head = build_biology_asset(zones(:head), body_id: 999)
|
||||
@alt_style.swf_assets = [alt_head]
|
||||
pet_state = build_pet_state(@pet_type)
|
||||
|
||||
# Create a body-specific item for the alt style's body_id
|
||||
body_specific_asset = build_item_asset(zones(:hat1), body_id: 999)
|
||||
body_specific_item = build_item("Body-specific Hat", swf_assets: [body_specific_asset])
|
||||
|
||||
# Create a universal item (body_id=0)
|
||||
universal_asset = build_item_asset(zones(:background), body_id: 0)
|
||||
universal_item = build_item("Universal Background", swf_assets: [universal_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
|
||||
outfit.worn_items = [body_specific_item, universal_item]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Only the universal item should appear
|
||||
expect(layers).to contain_exactly(alt_head, universal_asset)
|
||||
expect(layers).not_to include(body_specific_asset)
|
||||
end
|
||||
|
||||
it "does not include items from the regular pet's body_id" do
|
||||
# Create alt style layers
|
||||
alt_body = build_biology_asset(zones(:body), body_id: 999)
|
||||
@alt_style.swf_assets = [alt_body]
|
||||
pet_state = build_pet_state(@pet_type)
|
||||
|
||||
# Create an item that fits the regular pet's body_id (1)
|
||||
regular_item_asset = build_item_asset(zones(:hat1), body_id: 1)
|
||||
regular_item = build_item("Regular Pet Hat", swf_assets: [regular_item_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
|
||||
outfit.worn_items = [regular_item]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# The regular pet item should not appear on the alt style
|
||||
expect(layers).to contain_exactly(alt_body)
|
||||
expect(layers).not_to include(regular_item_asset)
|
||||
end
|
||||
|
||||
it "applies item restriction rules with alt styles" do
|
||||
# Create alt style layers including hair
|
||||
alt_head = build_biology_asset(zones(:head), body_id: 999)
|
||||
alt_hair = build_biology_asset(zones(:hairfront), body_id: 999)
|
||||
@alt_style.swf_assets = [alt_head, alt_hair]
|
||||
pet_state = build_pet_state(@pet_type)
|
||||
|
||||
# Create a universal hat that restricts the hair zone
|
||||
zones_restrict = "0" * 36 + "1" + "0" * 20 # bit 37 (Hair Front) = 1
|
||||
hat_asset = build_item_asset(zones(:hat1), body_id: 0, zones_restrict: zones_restrict)
|
||||
hat = build_item("Hair-hiding Hat", swf_assets: [hat_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
|
||||
outfit.worn_items = [hat]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Hair should be hidden by the hat's zone restrictions
|
||||
expect(layers).to contain_exactly(alt_head, hat_asset)
|
||||
expect(layers).not_to include(alt_hair)
|
||||
end
|
||||
|
||||
it "applies pet restriction rules with alt styles" do
|
||||
# Create alt style with a layer that restricts a zone
|
||||
alt_head = build_biology_asset(zones(:head), body_id: 999)
|
||||
zones_restrict = "0" * 47 + "1" + "0" * 10 # bit 48 (Background Item) = 1
|
||||
restricting_layer = build_biology_asset(zones(:body), body_id: 999, zones_restrict: zones_restrict)
|
||||
@alt_style.swf_assets = [alt_head, restricting_layer]
|
||||
pet_state = build_pet_state(@pet_type)
|
||||
|
||||
# Create a universal Background Item
|
||||
bg_item_asset = build_item_asset(zones(:backgrounditem), body_id: 0)
|
||||
bg_item = build_item("Universal Background Item", swf_assets: [bg_item_asset])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
|
||||
outfit.worn_items = [bg_item]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# body_id=0 items should still appear even in restricted zones
|
||||
# (because they're not body-specific)
|
||||
expect(layers).to contain_exactly(alt_head, restricting_layer, bg_item_asset)
|
||||
end
|
||||
|
||||
it "applies self-restriction rules with alt styles" do
|
||||
# Create alt style that restricts its own horn layer
|
||||
alt_body = build_biology_asset(zones(:body), body_id: 999)
|
||||
alt_horn = build_biology_asset(zones(:headtransientbiology), body_id: 999)
|
||||
zones_restrict = "0" * 37 + "1" + "0" * 20 # bit 38 (Head Transient Biology) = 1
|
||||
restricting_layer = build_biology_asset(zones(:head), body_id: 999, zones_restrict: zones_restrict)
|
||||
@alt_style.swf_assets = [alt_body, alt_horn, restricting_layer]
|
||||
pet_state = build_pet_state(@pet_type)
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# The horn should be hidden by the alt style's own restrictions
|
||||
expect(layers).to contain_exactly(alt_body, restricting_layer)
|
||||
expect(layers).not_to include(alt_horn)
|
||||
end
|
||||
|
||||
it "sorts alt style and item layers by depth correctly" do
|
||||
# Create alt style layers at various depths
|
||||
alt_background = build_biology_asset(zones(:background), body_id: 999) # depth 3
|
||||
alt_body = build_biology_asset(zones(:body), body_id: 999) # depth 18
|
||||
alt_head = build_biology_asset(zones(:head), body_id: 999) # depth 34
|
||||
@alt_style.swf_assets = [alt_head, alt_background, alt_body]
|
||||
pet_state = build_pet_state(@pet_type)
|
||||
|
||||
# Add universal items at various depths
|
||||
bg_item = build_item_asset(zones(:backgrounditem), body_id: 0) # depth 4
|
||||
trinket = build_item_asset(zones(:righthanditem1), body_id: 0) # depth 46
|
||||
|
||||
bg = build_item("Background Item", swf_assets: [bg_item])
|
||||
trinket_item = build_item("Trinket", swf_assets: [trinket])
|
||||
|
||||
outfit = Outfit.new(pet_state: pet_state, alt_style: @alt_style)
|
||||
outfit.worn_items = [trinket_item, bg]
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
# Expected order by depth:
|
||||
# alt_background (3), bg_item (4), alt_body (18), alt_head (34), trinket (46)
|
||||
expect(layers.map(&:depth)).to eq([3, 4, 18, 34, 46])
|
||||
expect(layers).to eq([alt_background, bg_item, alt_body, alt_head, trinket])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
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"
|
||||
|
||||
def self.fetch_viewer_data(pet_name, ...)
|
||||
File.open(DATA_DIR / "#{pet_name}.json") do |file|
|
||||
HashWithIndifferentAccess.new JSON.load(file)
|
||||
# NOTE: Windows doesn't support `@` in filenames, so we use a `scis` directory instead.
|
||||
path = if pet_name.start_with?('@')
|
||||
DATA_DIR / "scis" / "#{pet_name[1..]}.json"
|
||||
else
|
||||
DATA_DIR / "#{pet_name}.json"
|
||||
end
|
||||
|
||||
File.open(path) { |f| HashWithIndifferentAccess.new JSON.load(f) }
|
||||
end
|
||||
|
||||
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