Compare commits
4 commits
main
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
| f311c92dbb | |||
| 9ba94f9f4b | |||
| 2c21269a16 | |||
| 604a8667cf |
307 changed files with 1007 additions and 3087 deletions
|
|
@ -7,7 +7,7 @@ services:
|
|||
dockerfile: .devcontainer/Dockerfile
|
||||
|
||||
volumes:
|
||||
- ../..:/workspaces:cached
|
||||
- ../..:/workspaces:cached
|
||||
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: sleep infinity
|
||||
|
|
@ -18,20 +18,17 @@ services:
|
|||
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
|
||||
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||
depends_on:
|
||||
- mysql
|
||||
|
||||
environment:
|
||||
DB_USER: root
|
||||
- mysql
|
||||
|
||||
mysql:
|
||||
image: mariadb:10.6
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 'true'
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: 'true'
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- mysql-data:/var/lib/mysql
|
||||
networks:
|
||||
- default
|
||||
- default
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
|
|
|
|||
24
.solargraph.yml
Normal file
24
.solargraph.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
include:
|
||||
- "app/**/*.rb"
|
||||
- "config/**/*.rb"
|
||||
exclude:
|
||||
- "spec/**/*"
|
||||
- "test/**/*"
|
||||
- "vendor/**/*"
|
||||
- ".bundle/**/*"
|
||||
require:
|
||||
- actioncable
|
||||
- actionmailer
|
||||
- actionpack
|
||||
- actionview
|
||||
- activemodel
|
||||
- activerecord
|
||||
- activesupport
|
||||
plugins:
|
||||
- solargraph-rails
|
||||
domains: []
|
||||
reporters:
|
||||
- require_not_found
|
||||
require_paths: []
|
||||
max_files: 5000
|
||||
29
Gemfile
29
Gemfile
|
|
@ -11,13 +11,14 @@ gem 'mysql2', '~> 0.5.5'
|
|||
|
||||
# For reading the .env file, which you can use in development to more easily
|
||||
# set environment variables for secret data.
|
||||
gem 'dotenv', '~> 3.2'
|
||||
gem 'dotenv-rails', '~> 2.8', '>= 2.8.1'
|
||||
|
||||
# For the asset pipeline: templates, CSS, JS, etc.
|
||||
gem 'sprockets', '~> 4.2'
|
||||
gem 'haml', '~> 7.2'
|
||||
gem 'haml', '~> 6.1', '>= 6.1.1'
|
||||
gem 'sass-rails', '~> 6.0'
|
||||
gem 'terser', '~> 1.1', '>= 1.1.17'
|
||||
gem 'react-rails', '~> 2.7', '>= 2.7.1'
|
||||
gem 'jsbundling-rails', '~> 1.3'
|
||||
gem 'turbo-rails', '~> 2.0'
|
||||
|
||||
|
|
@ -25,7 +26,7 @@ gem 'turbo-rails', '~> 2.0'
|
|||
gem 'devise', '~> 4.9', '>= 4.9.2'
|
||||
gem 'devise-encryptable', '~> 0.2.0'
|
||||
gem 'omniauth', '~> 2.1'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 2.0', '>= 2.0.1'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0'
|
||||
gem "omniauth_openid_connect", "~> 0.7.1"
|
||||
|
||||
# For pagination UI.
|
||||
|
|
@ -40,7 +41,7 @@ gem 'nokogiri', '~> 1.15', '>= 1.15.3'
|
|||
|
||||
# For safely rendering users' Markdown + HTML on item list pages.
|
||||
gem 'rdiscount', '~> 2.2', '>= 2.2.7.1'
|
||||
gem 'sanitize', '~> 7.0'
|
||||
gem 'sanitize', '~> 6.0', '>= 6.0.2'
|
||||
|
||||
# For working with Neopets APIs.
|
||||
# unstable version of RocketAMF interprets info registry as a hash instead of an array
|
||||
|
|
@ -61,9 +62,6 @@ gem "async", "~> 2.17", require: false
|
|||
gem "async-http", "~> 0.89.0", require: false
|
||||
gem "thread-local", "~> 1.1", require: false
|
||||
|
||||
# For image processing (outfit PNG rendering).
|
||||
gem "ruby-vips", "~> 2.2"
|
||||
|
||||
# For debugging.
|
||||
group :development do
|
||||
gem 'debug', '~> 1.9.2'
|
||||
|
|
@ -74,7 +72,7 @@ end
|
|||
gem 'bootsnap', '~> 1.16', require: false
|
||||
|
||||
# For investigating performance issues.
|
||||
gem 'rack-mini-profiler', '~> 4.0', '>= 4.0.1'
|
||||
gem "rack-mini-profiler", "~> 3.1"
|
||||
gem "memory_profiler", "~> 1.0"
|
||||
gem "stackprof", "~> 0.2.25"
|
||||
|
||||
|
|
@ -85,7 +83,16 @@ gem "sentry-rails", "~> 5.12"
|
|||
# For tasks that use shell commands.
|
||||
gem "shell", "~> 0.8.1"
|
||||
|
||||
# For workspace autocomplete.
|
||||
group :development do
|
||||
gem "solargraph", "~> 0.50.0"
|
||||
gem "solargraph-rails", "~> 1.1"
|
||||
end
|
||||
|
||||
# For automated tests.
|
||||
gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test]
|
||||
gem 'rails-controller-testing', group: [:test]
|
||||
gem "webmock", "~> 3.24", group: [:test]
|
||||
group :development, :test do
|
||||
gem "rspec-rails", "~> 7.0"
|
||||
end
|
||||
group :test do
|
||||
gem "webmock", "~> 3.24"
|
||||
end
|
||||
|
|
|
|||
423
Gemfile.lock
423
Gemfile.lock
|
|
@ -6,31 +6,29 @@ PATH
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
action_text-trix (2.1.16)
|
||||
railties
|
||||
actioncable (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actioncable (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionmailbox (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionmailer (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionpack (8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
|
|
@ -38,58 +36,58 @@ GEM
|
|||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.2)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actiontext (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionview (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activejob (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activerecord (8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activemodel (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activerecord (8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activestorage (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.2)
|
||||
activesupport (8.0.2)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
json
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
async (2.35.3)
|
||||
ast (2.4.3)
|
||||
async (2.27.0)
|
||||
console (~> 1.29)
|
||||
fiber-annotation
|
||||
io-event (~> 1.11)
|
||||
metrics (~> 0.12)
|
||||
traces (~> 0.18)
|
||||
async-container (0.29.0)
|
||||
traces (~> 0.15)
|
||||
async-container (0.24.0)
|
||||
async (~> 2.22)
|
||||
async-http (0.89.0)
|
||||
async (>= 2.10.2)
|
||||
|
|
@ -101,36 +99,41 @@ GEM
|
|||
protocol-http1 (~> 0.30)
|
||||
protocol-http2 (~> 0.22)
|
||||
traces (~> 0.10)
|
||||
async-http-cache (0.4.6)
|
||||
async-http-cache (0.4.5)
|
||||
async-http (~> 0.56)
|
||||
async-pool (0.11.1)
|
||||
async-pool (0.11.0)
|
||||
async (>= 2.0)
|
||||
async-service (0.17.0)
|
||||
async-service (0.13.0)
|
||||
async
|
||||
async-container (~> 0.28)
|
||||
string-format (~> 0.2)
|
||||
async-container (~> 0.16)
|
||||
attr_required (1.0.2)
|
||||
babel-source (5.8.35)
|
||||
babel-transpiler (0.7.0)
|
||||
babel-source (>= 4.0, < 6)
|
||||
execjs (~> 2.0)
|
||||
backport (1.2.0)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.21)
|
||||
bigdecimal (4.0.1)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.2.2)
|
||||
bindata (2.5.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.21.1)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
builder (3.3.0)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (3.0.2)
|
||||
console (1.34.2)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
console (1.32.0)
|
||||
fiber-annotation
|
||||
fiber-local (~> 1.1)
|
||||
json
|
||||
crack (1.0.1)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
date (3.5.1)
|
||||
date (3.4.1)
|
||||
debug (1.9.2)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
|
|
@ -143,12 +146,15 @@ GEM
|
|||
devise-encryptable (0.2.0)
|
||||
devise (>= 2.1.0)
|
||||
diff-lcs (1.6.2)
|
||||
dotenv (3.2.0)
|
||||
dotenv (2.8.1)
|
||||
dotenv-rails (2.8.1)
|
||||
dotenv (= 2.8.1)
|
||||
railties (>= 3.2)
|
||||
drb (2.2.3)
|
||||
e2mmap (0.1.0)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (6.0.1)
|
||||
erb (5.0.2)
|
||||
erubi (1.13.1)
|
||||
execjs (2.10.0)
|
||||
falcon (0.48.6)
|
||||
|
|
@ -164,81 +170,83 @@ GEM
|
|||
protocol-http (~> 0.31)
|
||||
protocol-rack (~> 0.7)
|
||||
samovar (~> 2.3)
|
||||
faraday (2.14.0)
|
||||
faraday (2.13.4)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-net_http (3.4.2)
|
||||
net-http (~> 0.5)
|
||||
ffi (1.17.3-aarch64-linux-gnu)
|
||||
ffi (1.17.3-arm64-darwin)
|
||||
ffi (1.17.3-x86_64-linux-gnu)
|
||||
faraday-net_http (3.4.1)
|
||||
net-http (>= 0.5.0)
|
||||
ffi (1.17.2)
|
||||
fiber-annotation (0.2.0)
|
||||
fiber-local (1.1.0)
|
||||
fiber-storage
|
||||
fiber-storage (1.0.1)
|
||||
globalid (1.3.0)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
haml (7.2.0)
|
||||
haml (6.3.0)
|
||||
temple (>= 0.8.2)
|
||||
thor
|
||||
tilt
|
||||
hashdiff (1.2.1)
|
||||
hashie (5.1.0)
|
||||
logger
|
||||
hashdiff (1.2.0)
|
||||
hashie (5.0.0)
|
||||
http_accept_language (2.1.1)
|
||||
i18n (1.14.8)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
io-console (0.8.2)
|
||||
io-endpoint (0.16.0)
|
||||
io-event (1.14.2)
|
||||
io-stream (0.11.1)
|
||||
irb (1.16.0)
|
||||
io-console (0.8.1)
|
||||
io-endpoint (0.15.2)
|
||||
io-event (1.12.1)
|
||||
io-stream (0.10.0)
|
||||
irb (1.15.2)
|
||||
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)
|
||||
json-jwt (1.17.0)
|
||||
json (2.13.1)
|
||||
json-jwt (1.16.7)
|
||||
activesupport (>= 4.2)
|
||||
aes_key_wrap
|
||||
base64
|
||||
bindata
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
kramdown (2.5.1)
|
||||
rexml (>= 3.3.9)
|
||||
kramdown-parser-gfm (1.1.0)
|
||||
kramdown (~> 2.0)
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.1.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
localhost (1.7.0)
|
||||
lint_roller (1.1.0)
|
||||
localhost (1.5.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.25.0)
|
||||
loofah (2.24.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.9.0)
|
||||
logger
|
||||
mail (2.8.1)
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
mapping (1.1.3)
|
||||
marcel (1.1.0)
|
||||
marcel (1.0.4)
|
||||
memory_profiler (1.1.0)
|
||||
metrics (0.15.0)
|
||||
metrics (0.12.2)
|
||||
mini_mime (1.1.5)
|
||||
minitest (6.0.1)
|
||||
prism (~> 1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (5.25.5)
|
||||
msgpack (1.8.0)
|
||||
mysql2 (0.5.7)
|
||||
bigdecimal
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-imap (0.6.2)
|
||||
mysql2 (0.5.6)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.9)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
|
|
@ -247,19 +255,15 @@ GEM
|
|||
timeout
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.0-aarch64-linux-gnu)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.9)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.0-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.0-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
omniauth (2.1.4)
|
||||
omniauth (2.1.3)
|
||||
hashie (>= 3.4.6)
|
||||
logger
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-rails_csrf_protection (2.0.1)
|
||||
omniauth-rails_csrf_protection (1.0.2)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
omniauth_openid_connect (0.7.1)
|
||||
|
|
@ -278,45 +282,49 @@ GEM
|
|||
tzinfo
|
||||
validate_url
|
||||
webfinger (~> 2.0)
|
||||
openssl (3.3.2)
|
||||
openssl (3.3.0)
|
||||
orm_adapter (0.5.0)
|
||||
pp (0.6.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.9.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.8.0)
|
||||
process-metrics (0.8.0)
|
||||
prism (1.4.0)
|
||||
process-metrics (0.5.1)
|
||||
console (~> 1.8)
|
||||
json (~> 2)
|
||||
samovar (~> 2.1)
|
||||
protocol-hpack (1.5.1)
|
||||
protocol-http (0.58.0)
|
||||
protocol-http1 (0.36.0)
|
||||
protocol-http (~> 0.58)
|
||||
protocol-http2 (0.24.0)
|
||||
protocol-http (0.51.0)
|
||||
protocol-http1 (0.34.1)
|
||||
protocol-http (~> 0.22)
|
||||
protocol-http2 (0.22.1)
|
||||
protocol-hpack (~> 1.4)
|
||||
protocol-http (~> 0.47)
|
||||
protocol-rack (0.21.0)
|
||||
protocol-rack (0.15.0)
|
||||
io-stream (>= 0.10)
|
||||
protocol-http (~> 0.58)
|
||||
protocol-http (~> 0.43)
|
||||
rack (>= 1.0)
|
||||
psych (5.3.1)
|
||||
psych (5.2.6)
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.2)
|
||||
public_suffix (6.0.2)
|
||||
racc (1.8.1)
|
||||
rack (3.2.4)
|
||||
rack-attack (6.8.0)
|
||||
rack (3.1.16)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-mini-profiler (4.0.1)
|
||||
rack-mini-profiler (3.3.1)
|
||||
rack (>= 1.2.0)
|
||||
rack-oauth2 (2.3.0)
|
||||
rack-oauth2 (2.2.1)
|
||||
activesupport
|
||||
attr_required
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
json-jwt (>= 1.11.0)
|
||||
rack (>= 2.1.0)
|
||||
rack-protection (4.2.1)
|
||||
rack-protection (4.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
|
|
@ -325,26 +333,22 @@ GEM
|
|||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (8.1.2)
|
||||
actioncable (= 8.1.2)
|
||||
actionmailbox (= 8.1.2)
|
||||
actionmailer (= 8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actiontext (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
rails (8.0.2)
|
||||
actioncable (= 8.0.2)
|
||||
actionmailbox (= 8.0.2)
|
||||
actionmailer (= 8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actiontext (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.2)
|
||||
rails-controller-testing (1.0.5)
|
||||
actionpack (>= 5.0.1.rc1)
|
||||
actionview (>= 5.0.1.rc1)
|
||||
activesupport (>= 5.0.1.rc1)
|
||||
railties (= 8.0.2)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
|
|
@ -352,56 +356,78 @@ GEM
|
|||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (8.1.0)
|
||||
rails-i18n (8.0.1)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 8.0.0, < 9)
|
||||
railties (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
railties (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rake (13.3.1)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.0)
|
||||
rbs (2.8.4)
|
||||
rdiscount (2.2.7.3)
|
||||
rdoc (7.1.0)
|
||||
rdoc (6.14.2)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
reline (0.6.3)
|
||||
react-rails (2.7.1)
|
||||
babel-transpiler (>= 0.7.0)
|
||||
connection_pool
|
||||
execjs
|
||||
railties (>= 3.2)
|
||||
tilt
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.2)
|
||||
io-console (~> 0.5)
|
||||
responders (3.2.0)
|
||||
actionpack (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rexml (3.4.4)
|
||||
rspec-core (3.13.6)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
reverse_markdown (2.1.1)
|
||||
nokogiri
|
||||
rexml (3.4.1)
|
||||
rspec-core (3.13.5)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.7)
|
||||
rspec-mocks (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (8.0.2)
|
||||
actionpack (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
rspec-rails (7.1.1)
|
||||
actionpack (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.6)
|
||||
ruby-vips (2.3.0)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
samovar (2.4.1)
|
||||
rspec-support (3.13.4)
|
||||
rubocop (1.79.0)
|
||||
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.46.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
tsort (>= 0.2.0)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.46.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
ruby-progressbar (1.13.0)
|
||||
samovar (2.3.0)
|
||||
console (~> 1.0)
|
||||
mapping (~> 1.0)
|
||||
sanitize (7.0.0)
|
||||
sanitize (6.1.3)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.16.8)
|
||||
nokogiri (>= 1.12.0)
|
||||
sass-rails (6.0.0)
|
||||
sassc-rails (~> 2.1, >= 2.1.1)
|
||||
sassc (2.4.0)
|
||||
|
|
@ -413,15 +439,34 @@ GEM
|
|||
sprockets-rails
|
||||
tilt
|
||||
securerandom (0.4.1)
|
||||
sentry-rails (5.28.1)
|
||||
sentry-rails (5.26.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.28.1)
|
||||
sentry-ruby (5.28.1)
|
||||
sentry-ruby (~> 5.26.0)
|
||||
sentry-ruby (5.26.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
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.1.2)
|
||||
activesupport
|
||||
solargraph (>= 0.48.0, < 0.53.0)
|
||||
sprockets (4.2.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
logger
|
||||
|
|
@ -431,8 +476,7 @@ GEM
|
|||
activesupport (>= 6.1)
|
||||
sprockets (>= 3.0.0)
|
||||
stackprof (0.2.27)
|
||||
string-format (0.2.0)
|
||||
stringio (3.2.0)
|
||||
stringio (3.1.7)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
attr_required (>= 0.0.5)
|
||||
|
|
@ -442,18 +486,21 @@ GEM
|
|||
temple (0.10.4)
|
||||
terser (1.2.6)
|
||||
execjs (>= 0.3.0, < 3)
|
||||
thor (1.5.0)
|
||||
thor (1.4.0)
|
||||
thread-local (1.1.0)
|
||||
tilt (2.7.0)
|
||||
timeout (0.6.0)
|
||||
traces (0.18.2)
|
||||
tilt (2.6.1)
|
||||
timeout (0.4.3)
|
||||
traces (0.15.2)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.21)
|
||||
turbo-rails (2.0.16)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
uri (1.1.1)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.3)
|
||||
useragent (0.16.11)
|
||||
validate_url (1.0.15)
|
||||
activemodel (>= 3.0.0)
|
||||
|
|
@ -469,7 +516,7 @@ GEM
|
|||
activesupport
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
webmock (3.26.1)
|
||||
webmock (3.25.1)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
|
|
@ -478,12 +525,11 @@ GEM
|
|||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
will_paginate (4.0.1)
|
||||
zeitwerk (2.7.4)
|
||||
yard (0.9.37)
|
||||
zeitwerk (2.7.3)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
arm64-darwin
|
||||
x86_64-linux
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
RocketAMF!
|
||||
|
|
@ -494,9 +540,9 @@ DEPENDENCIES
|
|||
debug (~> 1.9.2)
|
||||
devise (~> 4.9, >= 4.9.2)
|
||||
devise-encryptable (~> 0.2.0)
|
||||
dotenv (~> 3.2)
|
||||
dotenv-rails (~> 2.8, >= 2.8.1)
|
||||
falcon (~> 0.48.0)
|
||||
haml (~> 7.2)
|
||||
haml (~> 6.1, >= 6.1.1)
|
||||
http_accept_language (~> 2.1, >= 2.1.1)
|
||||
jsbundling-rails (~> 1.3)
|
||||
letter_opener (~> 1.8, >= 1.8.1)
|
||||
|
|
@ -504,21 +550,22 @@ DEPENDENCIES
|
|||
mysql2 (~> 0.5.5)
|
||||
nokogiri (~> 1.15, >= 1.15.3)
|
||||
omniauth (~> 2.1)
|
||||
omniauth-rails_csrf_protection (~> 2.0, >= 2.0.1)
|
||||
omniauth-rails_csrf_protection (~> 1.0)
|
||||
omniauth_openid_connect (~> 0.7.1)
|
||||
rack-attack (~> 6.7)
|
||||
rack-mini-profiler (~> 4.0, >= 4.0.1)
|
||||
rack-mini-profiler (~> 3.1)
|
||||
rails (~> 8.0, >= 8.0.1)
|
||||
rails-controller-testing
|
||||
rails-i18n (~> 8.0, >= 8.0.1)
|
||||
rdiscount (~> 2.2, >= 2.2.7.1)
|
||||
rspec-rails (~> 8.0, >= 8.0.2)
|
||||
ruby-vips (~> 2.2)
|
||||
sanitize (~> 7.0)
|
||||
react-rails (~> 2.7, >= 2.7.1)
|
||||
rspec-rails (~> 7.0)
|
||||
sanitize (~> 6.0, >= 6.0.2)
|
||||
sass-rails (~> 6.0)
|
||||
sentry-rails (~> 5.12)
|
||||
sentry-ruby (~> 5.12)
|
||||
shell (~> 0.8.1)
|
||||
solargraph (~> 0.50.0)
|
||||
solargraph-rails (~> 1.1)
|
||||
sprockets (~> 4.2)
|
||||
stackprof (~> 0.2.25)
|
||||
terser (~> 1.1, >= 1.1.17)
|
||||
|
|
|
|||
26
README.md
26
README.md
|
|
@ -11,7 +11,7 @@ DTI is a Rails application with a React-based outfit editor, backed by MySQL dat
|
|||
### Core Components
|
||||
|
||||
- **Rails backend** (Ruby 3.4, Rails 8.0): Serves web pages, API endpoints, and manages data
|
||||
- **MySQL databases**: Primary database (`openneo_impress`) + legacy auth database (`openneo_id`)
|
||||
- **MySQL database**: Single database (`openneo_impress`) containing all application and authentication data
|
||||
- **React outfit editor**: Embedded in `app/javascript/wardrobe-2020/`, provides the main customization UI
|
||||
- **Modeling system**: Crowdsources pet/item appearance data by fetching from Neopets APIs when users load their pets
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ app/
|
|||
```
|
||||
config/
|
||||
├── routes.rb # All Rails routes
|
||||
├── database.yml # Multi-database setup (main + openneo_id)
|
||||
├── database.yml # Database configuration
|
||||
└── environments/
|
||||
└── *.rb # Env-specific config (incl. impress_2020_origin)
|
||||
```
|
||||
|
|
@ -117,7 +117,7 @@ config/
|
|||
|
||||
- **Backend**: Ruby on Rails (Ruby 3.4, Rails 8.0)
|
||||
- **Frontend**: Mix of Rails views (Turbo/HAML) and React (for outfit editor)
|
||||
- **Database**: MySQL (two databases: `openneo_impress`, `openneo_id`)
|
||||
- **Database**: MySQL (`openneo_impress`)
|
||||
- **Styling**: CSS, Sass (moving toward modern Rails conventions)
|
||||
- **External Integrations**:
|
||||
- **Neopets.com**: Legacy Flash/AMF protocol for pet appearance data (modeling)
|
||||
|
|
@ -129,16 +129,15 @@ config/
|
|||
|
||||
## Development Notes
|
||||
|
||||
### OpenNeo ID Database
|
||||
### Authentication Architecture
|
||||
|
||||
The `openneo_id` database is a legacy from when authentication was a separate service ("OpenNeo ID") meant to unify auth across multiple OpenNeo projects. DTI was the only project that succeeded, so the apps were merged—but the database split remains for now.
|
||||
Authentication data lives in the `auth_users` table (managed by the `AuthUser` model). This was historically in a separate `openneo_id` database (a legacy from when "OpenNeo ID" was envisioned as a service to unify auth across multiple OpenNeo projects). As of November 2025, the databases have been consolidated into a single database for simplicity.
|
||||
|
||||
**Implications**:
|
||||
- Rails is configured for multi-database mode
|
||||
- User auth models live in `auth_user.rb` and connect to `openneo_id`
|
||||
- **⚠️ CRITICAL**: Impress 2020 also directly accesses both `openneo_impress` and `openneo_id` databases via SQL
|
||||
- **Database migrations affecting these schemas must consider Impress 2020's direct access**
|
||||
- See [docs/impress-2020-dependencies.md](./docs/impress-2020-dependencies.md) for full details on this dependency
|
||||
User accounts are split across two related tables:
|
||||
- `auth_users` - Authentication data (passwords, email, OAuth connections) via Devise
|
||||
- `users` - Application data (points, closet settings, etc.)
|
||||
|
||||
These are linked via `User.remote_id` → `AuthUser.id`. See `db/openneo_id_migrate/README.md` for the historical context.
|
||||
|
||||
### Rails/React Hybrid
|
||||
|
||||
|
|
@ -154,10 +153,7 @@ The goal is to simplify this over time—either consolidate into Rails+Turbo, or
|
|||
|
||||
- **Main app**: VPS running Rails (Puma, MySQL)
|
||||
- **Impress 2020**: Separate VPS in same datacenter (NextJS, GraphQL, headless browser for images)
|
||||
- **Shared databases**: Both services directly access the same MySQL databases over the network
|
||||
- `openneo_impress` - Main application data
|
||||
- `openneo_id` - Authentication data
|
||||
- ⚠️ **Any database schema changes must be compatible with both services**
|
||||
- Both services share the same MySQL database (Impress 2020 makes SQL calls over the network)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -150,13 +150,13 @@ function updateStage() {
|
|||
|
||||
function updateCanvasDimensions() {
|
||||
// Set the canvas's internal dimensions to be higher, if the device has high
|
||||
// DPI. Scale the stage to match, too.
|
||||
// DPI. Scale the movie clip to match, too.
|
||||
const internalWidth = canvas.offsetWidth * window.devicePixelRatio;
|
||||
const internalHeight = canvas.offsetHeight * window.devicePixelRatio;
|
||||
canvas.width = internalWidth;
|
||||
canvas.height = internalHeight;
|
||||
stage.scaleX = internalWidth / library.properties.width;
|
||||
stage.scaleY = internalHeight / library.properties.height;
|
||||
movieClip.scaleX = internalWidth / library.properties.width;
|
||||
movieClip.scaleY = internalHeight / library.properties.height;
|
||||
}
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
|
|
@ -176,28 +176,23 @@ window.addEventListener("resize", () => {
|
|||
////////////////////////////////////////////////////
|
||||
|
||||
async function startMovie() {
|
||||
// Install the MotionGuidePlugin, which is needed for motion path animations.
|
||||
createjs.MotionGuidePlugin.install();
|
||||
|
||||
// Load the movie's library (from the JS file already run), and use it to
|
||||
// build a movie clip.
|
||||
library = await getLibrary();
|
||||
movieClip = buildMovieClip(library);
|
||||
|
||||
updateCanvasDimensions();
|
||||
|
||||
if (canvas.getContext("2d") == null) {
|
||||
console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
|
||||
// TODO: "Too many animations!"
|
||||
return;
|
||||
}
|
||||
|
||||
stage = new library.Stage(canvas);
|
||||
stage = new window.createjs.Stage(canvas);
|
||||
stage.addChild(movieClip);
|
||||
updateCanvasDimensions();
|
||||
updateStage();
|
||||
|
||||
// Signal to the library that the composition is ready.
|
||||
AdobeAn.compositionLoaded(library.properties.id);
|
||||
|
||||
loadingStatus = "loaded";
|
||||
canvas.setAttribute("data-status", "loaded");
|
||||
|
||||
|
|
|
|||
|
|
@ -2,15 +2,7 @@
|
|||
|
||||
body.users-top_contributors
|
||||
text-align: center
|
||||
|
||||
.timeframe-nav
|
||||
margin: 1em 0
|
||||
display: flex
|
||||
justify-content: center
|
||||
gap: 1em
|
||||
list-style: none
|
||||
padding: 0
|
||||
|
||||
|
||||
#top-contributors
|
||||
border:
|
||||
spacing: 0
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ class ApplicationController < ActionController::Base
|
|||
before_action :save_return_to_path,
|
||||
if: ->(c) { c.controller_name == 'sessions' && c.action_name == 'new' }
|
||||
|
||||
# Enable profiling tools in development or when logged in as an admin.
|
||||
# Enable profiling tools if logged in as admin.
|
||||
before_action do
|
||||
Rack::MiniProfiler.authorize_request if Rails.env.development? || current_user&.admin?
|
||||
if current_user && current_user.admin?
|
||||
Rack::MiniProfiler.authorize_request
|
||||
end
|
||||
end
|
||||
|
||||
class AccessDenied < StandardError; end
|
||||
|
|
|
|||
|
|
@ -3,12 +3,9 @@ class ItemTradesController < ApplicationController
|
|||
@item = Item.find params[:item_id]
|
||||
@type = type_from_params
|
||||
|
||||
@item_trades = @item.visible_trades(
|
||||
scope: ClosetHanger.includes(:user, :list).
|
||||
order('users.last_trade_activity_at DESC'),
|
||||
user: current_user,
|
||||
remote_ip: request.remote_ip
|
||||
)
|
||||
@item_trades = @item.closet_hangers.trading.includes(:user, :list).
|
||||
user_is_active.order('users.last_trade_activity_at DESC').
|
||||
to_trades(current_user, request.remote_ip)
|
||||
@trades = @item_trades[@type]
|
||||
|
||||
if user_signed_in?
|
||||
|
|
|
|||
|
|
@ -80,10 +80,8 @@ class ItemsController < ApplicationController
|
|||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
@trades = @item.visible_trades(
|
||||
user: current_user,
|
||||
remote_ip: request.remote_ip
|
||||
)
|
||||
@trades = @item.closet_hangers.trading.user_is_active.
|
||||
to_trades(current_user, request.remote_ip)
|
||||
|
||||
@contributors_with_counts = @item.contributors_with_counts
|
||||
|
||||
|
|
@ -109,15 +107,6 @@ class ItemsController < ApplicationController
|
|||
includes(:species).merge(Species.alphabetical)
|
||||
end
|
||||
|
||||
format.json do
|
||||
render json: @item.as_json(
|
||||
include_trade_counts: true,
|
||||
include_nc_trade_value: true,
|
||||
current_user: current_user,
|
||||
remote_ip: request.remote_ip
|
||||
)
|
||||
end
|
||||
|
||||
format.gif do
|
||||
expires_in 1.month
|
||||
redirect_to @item.thumbnail_url, allow_other_host: true
|
||||
|
|
|
|||
|
|
@ -13,26 +13,7 @@ class OutfitsController < ApplicationController
|
|||
end
|
||||
|
||||
def edit
|
||||
respond_to do |format|
|
||||
format.html { render "outfits/edit", layout: false }
|
||||
format.png do
|
||||
@outfit = build_outfit_from_wardrobe_params
|
||||
if @outfit.valid?
|
||||
renderer = OutfitImageRenderer.new(@outfit)
|
||||
png_data = renderer.render
|
||||
|
||||
if png_data
|
||||
send_data png_data, type: "image/png", disposition: "inline",
|
||||
filename: "outfit.png"
|
||||
expires_in 1.day, public: true
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
else
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
end
|
||||
render "outfits/edit", layout: false
|
||||
end
|
||||
|
||||
def index
|
||||
|
|
@ -136,40 +117,6 @@ class OutfitsController < ApplicationController
|
|||
biology: [:species_id, :color_id, :pose, :pet_state_id])
|
||||
end
|
||||
|
||||
def build_outfit_from_wardrobe_params
|
||||
# Load items first
|
||||
worn_item_ids = params[:objects] ? Array(params[:objects]).map(&:to_i) : []
|
||||
closeted_item_ids = params[:closet] ? Array(params[:closet]).map(&:to_i) : []
|
||||
|
||||
worn_items = Item.where(id: worn_item_ids)
|
||||
closeted_items = Item.where(id: closeted_item_ids)
|
||||
|
||||
# Build outfit with biology and items
|
||||
outfit = Outfit.new(
|
||||
worn_items: worn_items,
|
||||
closeted_items: closeted_items,
|
||||
)
|
||||
|
||||
# Set biology from species, color, and pose params
|
||||
if params[:species] && params[:color] && params[:pose]
|
||||
outfit.biology = {
|
||||
species_id: params[:species],
|
||||
color_id: params[:color],
|
||||
pose: params[:pose]
|
||||
}
|
||||
elsif params[:state]
|
||||
# Alternative: use pet_state_id directly
|
||||
outfit.biology = { pet_state_id: params[:state] }
|
||||
end
|
||||
|
||||
# Set alt style if provided
|
||||
if params[:style]
|
||||
outfit.alt_style_id = params[:style].to_i
|
||||
end
|
||||
|
||||
outfit
|
||||
end
|
||||
|
||||
def find_authorized_outfit
|
||||
raise ActiveRecord::RecordNotFound unless user_signed_in?
|
||||
@outfit = current_user.outfits.find(params[:id])
|
||||
|
|
|
|||
|
|
@ -14,10 +14,7 @@ class UsersController < ApplicationController
|
|||
end
|
||||
|
||||
def top_contributors
|
||||
valid_timeframes = User::VALID_TIMEFRAMES.map(&:to_s)
|
||||
@timeframe = params[:timeframe].presence_in(valid_timeframes) || 'all_time'
|
||||
@users = User.top_contributors_for(@timeframe.to_sym)
|
||||
.paginate(page: params[:page], per_page: 20)
|
||||
@users = User.top_contributors.paginate :page => params[:page], :per_page => 20
|
||||
end
|
||||
|
||||
def edit
|
||||
|
|
|
|||
|
|
@ -141,13 +141,6 @@ module ItemsHelper
|
|||
def auction_genie_url_for(item)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -390,10 +390,6 @@ export function loadMovieLibrary(librarySrc, { preferArchive = false } = {}) {
|
|||
);
|
||||
}
|
||||
delete window.AdobeAn.compositions[compositionId];
|
||||
|
||||
// Install the MotionGuidePlugin, which is needed for motion path animations.
|
||||
window.createjs.MotionGuidePlugin.install();
|
||||
|
||||
const library = composition.getLibrary();
|
||||
|
||||
// One more loading step as part of loading this library is loading the
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
class AuthRecord < ApplicationRecord
|
||||
self.abstract_class = true
|
||||
|
||||
connects_to database: {reading: :openneo_id, writing: :openneo_id}
|
||||
end
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
class AuthUser < AuthRecord
|
||||
self.table_name = 'users'
|
||||
class AuthUser < ApplicationRecord
|
||||
# TODO: Consider merging with User model to eliminate the remote_id relationship
|
||||
# and simplify the authentication architecture. This would involve combining the
|
||||
# auth_users and users tables into a single table.
|
||||
|
||||
devise :database_authenticatable, :encryptable, :registerable, :validatable,
|
||||
:rememberable, :trackable, :recoverable, :omniauthable,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class Item < ApplicationRecord
|
|||
attr_writer :current_body_id, :owned, :wanted
|
||||
|
||||
NCRarities = [0, 500]
|
||||
PAINTBRUSH_SET_DESCRIPTION = 'This item is part of a deluxe paint brush set'
|
||||
PAINTBRUSH_SET_DESCRIPTION = 'This item is part of a deluxe paint brush set!'
|
||||
|
||||
scope :newest, -> {
|
||||
order(arel_table[:created_at].desc) if arel_table[:created_at]
|
||||
|
|
@ -162,7 +162,7 @@ class Item < ApplicationRecord
|
|||
end
|
||||
|
||||
def pb?
|
||||
I18n.with_locale(:en) { self.description.include?(PAINTBRUSH_SET_DESCRIPTION) }
|
||||
I18n.with_locale(:en) { self.description == PAINTBRUSH_SET_DESCRIPTION }
|
||||
end
|
||||
|
||||
def np?
|
||||
|
|
@ -444,34 +444,11 @@ class Item < ApplicationRecord
|
|||
created_at || Time.new(2010)
|
||||
end
|
||||
|
||||
# Returns the visible trades for this item, filtered by user visibility.
|
||||
# Accepts an optional scope to add additional query constraints (e.g., includes, order).
|
||||
def visible_trades(scope: nil, user: nil, remote_ip: nil)
|
||||
base = closet_hangers.trading.user_is_active
|
||||
base = base.merge(scope) if scope
|
||||
base.to_trades(user, remote_ip)
|
||||
end
|
||||
|
||||
def as_json(options={})
|
||||
result = super({
|
||||
super({
|
||||
only: [:id, :name, :description, :thumbnail_url, :rarity_index],
|
||||
methods: [:zones_restrict],
|
||||
}.merge(options))
|
||||
|
||||
if options[:include_trade_counts]
|
||||
trades = visible_trades(
|
||||
user: options[:current_user],
|
||||
remote_ip: options[:remote_ip]
|
||||
)
|
||||
result['num_trades_offering'] = trades[:offering].size
|
||||
result['num_trades_seeking'] = trades[:seeking].size
|
||||
end
|
||||
|
||||
if options[:include_nc_trade_value]
|
||||
result['nc_trade_value'] = nc_trade_value
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def compatible_body_ids(use_cached: true)
|
||||
|
|
|
|||
|
|
@ -170,67 +170,52 @@ class Outfit < ApplicationRecord
|
|||
end
|
||||
|
||||
def visible_layers
|
||||
# Step 1: Choose biology layers - use alt style if present, otherwise pet state
|
||||
if alt_style
|
||||
biology_layers = alt_style.swf_assets.includes(:zone).to_a
|
||||
body = alt_style
|
||||
using_alt_style = true
|
||||
else
|
||||
biology_layers = pet_state.swf_assets.includes(:zone).to_a
|
||||
body = pet_type
|
||||
using_alt_style = false
|
||||
end
|
||||
# TODO: This method doesn't currently handle alt styles! If the outfit has
|
||||
# an alt_style, we should use its layers instead of pet_state layers, and
|
||||
# filter items to only those with body_id=0. This isn't needed yet because
|
||||
# this method is only used on item pages, which don't support alt styles.
|
||||
# See useOutfitAppearance.js for the complete logic including alt styles.
|
||||
item_appearances = item_appearances(swf_asset_includes: [:zone])
|
||||
|
||||
# Step 2: Load item appearances for the appropriate body
|
||||
item_appearances = Item.appearances_for(
|
||||
worn_items,
|
||||
body,
|
||||
swf_asset_includes: [:zone]
|
||||
).values
|
||||
pet_layers = pet_state.swf_assets.includes(:zone).to_a
|
||||
item_layers = item_appearances.map(&:swf_assets).flatten
|
||||
|
||||
# For alt styles, only body_id=0 items are compatible
|
||||
if using_alt_style
|
||||
item_layers.reject! { |sa| sa.body_id != 0 }
|
||||
end
|
||||
|
||||
# Step 3: Apply restriction rules
|
||||
biology_restricted_zone_ids = biology_layers.map(&:restricted_zone_ids).
|
||||
pet_restricted_zone_ids = pet_layers.map(&:restricted_zone_ids).
|
||||
flatten.to_set
|
||||
item_restricted_zone_ids = item_appearances.
|
||||
map(&:restricted_zone_ids).flatten.to_set
|
||||
|
||||
# Rule 3a: When an item restricts a zone, it hides biology layers of the same zone.
|
||||
# When an item restricts a zone, it hides pet layers of the same zone.
|
||||
# We use this to e.g. make a hat hide a hair ruff.
|
||||
#
|
||||
# NOTE: Items' restricted layers also affect what items you can wear at
|
||||
# the same time. We don't enforce anything about that here, and
|
||||
# instead assume that the input by this point is valid!
|
||||
biology_layers.reject! { |sa| item_restricted_zone_ids.include?(sa.zone_id) }
|
||||
pet_layers.reject! { |sa| item_restricted_zone_ids.include?(sa.zone_id) }
|
||||
|
||||
# Rule 3b: When a biology appearance restricts a zone, or when the pet is
|
||||
# Unconverted, it makes body-specific items incompatible. We use this to
|
||||
# disallow UCs from wearing certain body-specific Biology Effects, Statics,
|
||||
# etc, while still allowing non-body-specific items in those zones! (I think
|
||||
# this happens for some Invisible pet stuff, too?)
|
||||
# When a pet appearance restricts a zone, or when the pet is Unconverted,
|
||||
# it makes body-specific items incompatible. We use this to disallow UCs
|
||||
# from wearing certain body-specific Biology Effects, Statics, etc, while
|
||||
# still allowing non-body-specific items in those zones! (I think this
|
||||
# happens for some Invisible pet stuff, too?)
|
||||
#
|
||||
# TODO: We shouldn't be *hiding* these zones, like we do with items; we
|
||||
# should be doing this way earlier, to prevent the item from even
|
||||
# showing up even in search results!
|
||||
#
|
||||
# NOTE: This can result in both biology layers and items occupying the same
|
||||
# NOTE: This can result in both pet layers and items occupying the same
|
||||
# zone, like Static, so long as the item isn't body-specific! That's
|
||||
# correct, and the item layer should be on top! (Here, we implement
|
||||
# it by placing item layers second in the list, and rely on JS sort
|
||||
# stability, and *then* rely on the UI to respect that ordering when
|
||||
# rendering them by depth. Not great! 😅)
|
||||
#
|
||||
# NOTE: We used to also include the biology appearance's *occupied* zones in
|
||||
# NOTE: We used to also include the pet appearance's *occupied* zones in
|
||||
# this condition, not just the restricted zones, as a sensible
|
||||
# defensive default, even though we weren't aware of any relevant
|
||||
# items. But now we know that actually the "Bruce Brucey B Mouth"
|
||||
# occupies the real Mouth zone, and still should be visible and
|
||||
# above biology layers! So, we now only check *restricted* zones.
|
||||
# above pet layers! So, we now only check *restricted* zones.
|
||||
#
|
||||
# NOTE: UCs used to implement their restrictions by listing specific
|
||||
# zones, but it seems that the logic has changed to just be about
|
||||
|
|
@ -247,20 +232,18 @@ class Outfit < ApplicationRecord
|
|||
item_layers.reject! { |sa| sa.body_specific? }
|
||||
else
|
||||
item_layers.reject! { |sa| sa.body_specific? &&
|
||||
biology_restricted_zone_ids.include?(sa.zone_id) }
|
||||
pet_restricted_zone_ids.include?(sa.zone_id) }
|
||||
end
|
||||
|
||||
# Rule 3c: A biology appearance can also restrict its own zones. The Wraith
|
||||
# Uni is an interesting example: it has a horn, but its zone restrictions
|
||||
# hide it!
|
||||
biology_layers.reject! { |sa| biology_restricted_zone_ids.include?(sa.zone_id) }
|
||||
# A pet appearance can also restrict its own zones. The Wraith Uni is an
|
||||
# interesting example: it has a horn, but its zone restrictions hide it!
|
||||
pet_layers.reject! { |sa| pet_restricted_zone_ids.include?(sa.zone_id) }
|
||||
|
||||
# Step 4: Sort by depth and return
|
||||
(biology_layers + item_layers).sort_by(&:depth)
|
||||
(pet_layers + item_layers).sort_by(&:depth)
|
||||
end
|
||||
|
||||
def wardrobe_params
|
||||
params = {
|
||||
{
|
||||
name: name,
|
||||
color: color_id,
|
||||
species: species_id,
|
||||
|
|
@ -269,8 +252,6 @@ class Outfit < ApplicationRecord
|
|||
objects: worn_item_ids,
|
||||
closet: closeted_item_ids,
|
||||
}
|
||||
params[:style] = alt_style_id if alt_style_id.present?
|
||||
params
|
||||
end
|
||||
|
||||
def ensure_unique_name
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
# Pet::AutoModeling provides utilities for automatically modeling items on pet
|
||||
# bodies using the NC Mall preview API. This allows us to fetch appearance data
|
||||
# for items without needing a real pet of that type.
|
||||
#
|
||||
# The workflow:
|
||||
# 1. Generate a combined "SCI" (Species/Color Image hash) using NC Mall's
|
||||
# getPetData endpoint, which combines a pet type with items.
|
||||
# 2. Fetch the viewer data for that combined SCI using the CustomPets API.
|
||||
# 3. Process the viewer data to create SwfAsset records.
|
||||
module Pet::AutoModeling
|
||||
extend self
|
||||
|
||||
# Model an item on a specific body ID. This fetches the appearance data from
|
||||
# Neopets and creates/updates the SwfAsset records.
|
||||
#
|
||||
# @param item [Item] The item to model
|
||||
# @param body_id [Integer] The body ID to model on
|
||||
# @return [Symbol] Result status:
|
||||
# - :modeled - Successfully created SwfAsset records
|
||||
# - :not_compatible - Item is explicitly not compatible with this body
|
||||
# @raise [NoPetTypeForBody] If no PetType exists for this body_id
|
||||
# @raise [Neopets::NCMall::ResponseNotOK] On HTTP errors (transient for 5xx)
|
||||
# @raise [Neopets::NCMall::UnexpectedResponseFormat] On invalid response
|
||||
# @raise [Neopets::CustomPets::DownloadError] On AMF protocol errors
|
||||
def model_item_on_body(item, body_id)
|
||||
# Find a pet type with this body ID to use as a base
|
||||
pet_type = PetType.find_by(body_id: body_id)
|
||||
raise NoPetTypeForBody.new(body_id) if pet_type.nil?
|
||||
|
||||
# Fetch the viewer data for this item on this pet type
|
||||
new_image_hash = Neopets::NCMall.fetch_pet_data_sci(pet_type.image_hash, [item.id])
|
||||
viewer_data = Neopets::CustomPets.fetch_viewer_data("@#{new_image_hash}")
|
||||
|
||||
# If the item wasn't in the response, it's not compatible.
|
||||
object_info = viewer_data[:object_info_registry]&.to_h&.[](item.id.to_s)
|
||||
return :not_compatible if object_info.nil?
|
||||
|
||||
# Process the modeling data using the existing infrastructure
|
||||
snapshot = Pet::ModelingSnapshot.new(viewer_data)
|
||||
|
||||
# Save the pet type (may update image hash, etc.)
|
||||
snapshot.pet_type.save!
|
||||
|
||||
# Get the items from the snapshot and process them
|
||||
modeled_items = snapshot.items
|
||||
modeled_item = modeled_items.find { |i| i.id == item.id }
|
||||
|
||||
if modeled_item
|
||||
modeled_item.save!
|
||||
modeled_item.handle_assets!
|
||||
end
|
||||
|
||||
:modeled
|
||||
end
|
||||
|
||||
class NoPetTypeForBody < StandardError
|
||||
attr_reader :body_id
|
||||
def initialize(body_id)
|
||||
@body_id = body_id
|
||||
super("No PetType found for body_id=#{body_id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -3,6 +3,9 @@ class User < ApplicationRecord
|
|||
|
||||
PreviewTopContributorsCount = 3
|
||||
|
||||
# TODO: This relationship could be simplified by merging the auth_users and users
|
||||
# tables. Currently User.remote_id points to AuthUser.id, but if the tables were
|
||||
# merged, we could eliminate remote_id and auth_server_id entirely.
|
||||
belongs_to :auth_user, foreign_key: :remote_id, inverse_of: :user
|
||||
delegate :disconnect_neopass, :uses_neopass?, to: :auth_user
|
||||
|
||||
|
|
@ -25,51 +28,6 @@ class User < ApplicationRecord
|
|||
|
||||
scope :top_contributors, -> { order('points DESC').where('points > 0') }
|
||||
|
||||
VALID_TIMEFRAMES = [:all_time, :this_year, :this_month, :this_week]
|
||||
|
||||
scope :top_contributors_for, ->(timeframe = :all_time) {
|
||||
case timeframe.to_sym
|
||||
when :all_time
|
||||
top_contributors # Use existing efficient scope
|
||||
else
|
||||
top_contributors_by_period(timeframe)
|
||||
end
|
||||
}
|
||||
|
||||
def self.top_contributors_by_period(timeframe)
|
||||
start_date = case timeframe.to_sym
|
||||
when :this_week then 1.week.ago
|
||||
when :this_month then 1.month.ago
|
||||
when :this_year then 1.year.ago
|
||||
else raise ArgumentError, "Invalid timeframe: #{timeframe}"
|
||||
end
|
||||
|
||||
# Build the CASE statement dynamically from Contribution::POINT_VALUES
|
||||
point_case = Contribution::POINT_VALUES.map { |type, points|
|
||||
"WHEN #{connection.quote(type)} THEN #{points}"
|
||||
}.join("\n ")
|
||||
|
||||
select(
|
||||
'users.*',
|
||||
"COALESCE(SUM(
|
||||
CASE contributions.contributed_type
|
||||
#{point_case}
|
||||
END
|
||||
), 0) AS period_points"
|
||||
)
|
||||
.joins('INNER JOIN contributions ON contributions.user_id = users.id')
|
||||
.where('contributions.created_at >= ?', start_date)
|
||||
.group('users.id')
|
||||
.having('period_points > 0')
|
||||
.order('period_points DESC, users.id ASC')
|
||||
end
|
||||
|
||||
# Virtual attribute reader for dynamically calculated points (from time-period queries).
|
||||
# Falls back to the denormalized `points` column when not calculated.
|
||||
def period_points
|
||||
attributes['period_points'] || points
|
||||
end
|
||||
|
||||
after_update :sync_name_with_auth_user!, if: :saved_change_to_name?
|
||||
after_update :log_trade_activity, if: -> user {
|
||||
(user.saved_change_to_owned_closet_hangers_visibility? &&
|
||||
|
|
|
|||
|
|
@ -120,44 +120,6 @@ module Neopets::NCMall
|
|||
end
|
||||
end
|
||||
|
||||
# Generate a new image hash for a pet wearing specific items. Takes a base
|
||||
# pet sci (species/color image hash) and optional item IDs, and returns a
|
||||
# response containing the combined image hash in the :newsci field.
|
||||
# Use the returned hash with Neopets::CustomPets.fetch_viewer_data("@#{newsci}")
|
||||
# to get the full appearance data.
|
||||
PET_DATA_URL = "https://ncmall.neopets.com/mall/ajax/petview/getPetData.php"
|
||||
def self.fetch_pet_data_sci(pet_sci, item_ids = [])
|
||||
Sync do
|
||||
params = {"selPetsci" => pet_sci}
|
||||
item_ids.each { |id| params["itemsList[]"] = id.to_s }
|
||||
|
||||
DTIRequests.post(
|
||||
PET_DATA_URL,
|
||||
[["Content-Type", "application/x-www-form-urlencoded"]],
|
||||
params.to_query,
|
||||
) do |response|
|
||||
if response.status != 200
|
||||
raise ResponseNotOK.new(response.status),
|
||||
"expected status 200 but got #{response.status} (#{PET_DATA_URL})"
|
||||
end
|
||||
|
||||
begin
|
||||
data = JSON.parse(response.read)
|
||||
rescue JSON::ParserError
|
||||
raise UnexpectedResponseFormat,
|
||||
"failed to parse pet data response as JSON"
|
||||
end
|
||||
|
||||
unless data["newsci"].is_a?(String) && data["newsci"].present?
|
||||
raise UnexpectedResponseFormat,
|
||||
"missing or invalid field newsci in pet data response"
|
||||
end
|
||||
|
||||
data["newsci"]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Map load_type from menu JSON to the v2 API type parameter.
|
||||
|
|
|
|||
|
|
@ -33,13 +33,10 @@
|
|||
= link_to t('items.show.resources.jn_items'), jn_items_url_for(item)
|
||||
= link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item)
|
||||
- if item.nc_trade_value
|
||||
= link_to lebron_url_for(item),
|
||||
= link_to 'https://www.neopets.com/~lebron',
|
||||
title: nc_trade_value_updated_at_text(item.nc_trade_value) do
|
||||
= t 'items.show.resources.lebron_value',
|
||||
= t 'items.show.resources.lebron',
|
||||
value: nc_trade_value_estimate_text(item.nc_trade_value)
|
||||
- elsif item.nc?
|
||||
= link_to lebron_url_for(item) do
|
||||
= t 'items.show.resources.lebron'
|
||||
- unless item.nc?
|
||||
= link_to t('items.show.resources.shop_wizard'), shop_wizard_url_for(item)
|
||||
= link_to t('items.show.resources.trading_post'), trading_post_url_for(item)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,4 @@
|
|||
- title t('.title')
|
||||
|
||||
%ul.timeframe-nav
|
||||
- ['all_time', 'this_year', 'this_month', 'this_week'].each do |tf|
|
||||
%li
|
||||
- if @timeframe == tf
|
||||
%strong= t(".timeframes.#{tf}")
|
||||
- else
|
||||
= link_to t(".timeframes.#{tf}"), top_contributors_path(timeframe: tf)
|
||||
|
||||
= will_paginate @users
|
||||
%table#top-contributors
|
||||
%thead
|
||||
|
|
@ -20,5 +11,5 @@
|
|||
%tr
|
||||
%th{:scope => 'row'}= @users.offset + rank + 1
|
||||
%td= link_to user.name, user_contributions_path(user)
|
||||
%td= user.period_points
|
||||
%td= user.points
|
||||
= will_paginate @users
|
||||
|
|
|
|||
6
bin/ci
6
bin/ci
|
|
@ -1,6 +0,0 @@
|
|||
#!/usr/bin/env ruby
|
||||
require_relative "../config/boot"
|
||||
require "active_support/continuous_integration"
|
||||
|
||||
CI = ActiveSupport::ContinuousIntegration
|
||||
require_relative "../config/ci.rb"
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
#!/usr/bin/env ruby
|
||||
require "rubygems"
|
||||
require "bundler/setup"
|
||||
|
||||
# Explicit RuboCop config increases performance slightly while avoiding config confusion.
|
||||
ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
|
||||
|
||||
load Gem.bin_path("rubocop", "rubocop")
|
||||
|
|
@ -23,8 +23,6 @@ FileUtils.chdir APP_ROOT do
|
|||
puts "\n== Preparing database =="
|
||||
system! "bin/rails db:prepare"
|
||||
|
||||
system! "bin/rails db:reset" if ARGV.include?("--reset")
|
||||
|
||||
puts "\n== Importing public modeling data =="
|
||||
system! "bin/rails public_data:pull"
|
||||
|
||||
|
|
|
|||
27
bin/solargraph
Executable file
27
bin/solargraph
Executable file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
#
|
||||
# This file was generated by Bundler.
|
||||
#
|
||||
# The application 'solargraph' is installed as part of a gem, and
|
||||
# this file is here to facilitate running it.
|
||||
#
|
||||
|
||||
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
||||
|
||||
bundle_binstub = File.expand_path("bundle", __dir__)
|
||||
|
||||
if File.file?(bundle_binstub)
|
||||
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
|
||||
load(bundle_binstub)
|
||||
else
|
||||
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
||||
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
||||
end
|
||||
end
|
||||
|
||||
require "rubygems"
|
||||
require "bundler/setup"
|
||||
|
||||
load Gem.bin_path("solargraph", "solargraph")
|
||||
|
|
@ -25,7 +25,7 @@ Bundler.require(*Rails.groups)
|
|||
module OpenneoImpressItems
|
||||
class Application < Rails::Application
|
||||
# Initialize configuration defaults for originally generated Rails version.
|
||||
config.load_defaults 8.1
|
||||
config.load_defaults 8.0
|
||||
|
||||
# Please, add to the `ignore` list any other `lib` subdirectories that do
|
||||
# not contain `.rb` files, or that should not be reloaded or eager loaded.
|
||||
|
|
|
|||
21
config/ci.rb
21
config/ci.rb
|
|
@ -1,21 +0,0 @@
|
|||
# Run using bin/ci
|
||||
|
||||
CI.run do
|
||||
step "Setup", "bin/setup --skip-server"
|
||||
|
||||
step "Style: Ruby", "bin/rubocop"
|
||||
|
||||
step "Security: Importmap vulnerability audit", "bin/importmap audit"
|
||||
|
||||
step "Tests: Rails", "bin/rails test"
|
||||
step "Tests: System", "bin/rails test:system"
|
||||
step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant"
|
||||
|
||||
# Optional: set a green GitHub commit status to unblock PR merge.
|
||||
# Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
|
||||
# if success?
|
||||
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
|
||||
# else
|
||||
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
|
||||
# end
|
||||
end
|
||||
|
|
@ -1,57 +1,28 @@
|
|||
development:
|
||||
primary:
|
||||
adapter: mysql2
|
||||
host: <%= ENV.fetch("DB_HOST", "localhost") %>
|
||||
database: openneo_impress
|
||||
username: <%= ENV.fetch("DB_USER", ENV.fetch("USER", "root")) %>
|
||||
pool: 5
|
||||
encoding: utf8mb4
|
||||
collation: utf8mb4_unicode_520_ci
|
||||
variables:
|
||||
sql_mode: TRADITIONAL
|
||||
|
||||
openneo_id:
|
||||
adapter: mysql2
|
||||
host: <%= ENV.fetch("DB_HOST", "localhost") %>
|
||||
database: openneo_id
|
||||
username: <%= ENV.fetch("DB_USER", ENV.fetch("USER", "root")) %>
|
||||
pool: 2
|
||||
variables:
|
||||
sql_mode: TRADITIONAL
|
||||
migrations_paths: db/openneo_id_migrate
|
||||
adapter: mysql2
|
||||
host: <%= ENV.fetch("DB_HOST", "localhost") %>
|
||||
database: openneo_impress
|
||||
username: root
|
||||
pool: 5
|
||||
encoding: utf8mb4
|
||||
collation: utf8mb4_unicode_520_ci
|
||||
variables:
|
||||
sql_mode: TRADITIONAL
|
||||
|
||||
test:
|
||||
primary:
|
||||
adapter: mysql2
|
||||
host: <%= ENV.fetch("DB_HOST", "localhost") %>
|
||||
database: openneo_impress_test
|
||||
username: <%= ENV.fetch("DB_USER", ENV.fetch("USER", "root")) %>
|
||||
pool: 5
|
||||
encoding: utf8mb4
|
||||
collation: utf8mb4_unicode_520_ci
|
||||
variables:
|
||||
sql_mode: TRADITIONAL
|
||||
|
||||
openneo_id:
|
||||
adapter: mysql2
|
||||
host: <%= ENV.fetch("DB_HOST", "localhost") %>
|
||||
database: openneo_id_test
|
||||
username: <%= ENV.fetch("DB_USER", ENV.fetch("USER", "root")) %>
|
||||
pool: 2
|
||||
variables:
|
||||
sql_mode: TRADITIONAL
|
||||
migrations_paths: db/openneo_id_migrate
|
||||
adapter: mysql2
|
||||
host: <%= ENV.fetch("DB_HOST", "localhost") %>
|
||||
database: openneo_impress_test
|
||||
username: root
|
||||
pool: 5
|
||||
encoding: utf8mb4
|
||||
collation: utf8mb4_unicode_520_ci
|
||||
variables:
|
||||
sql_mode: TRADITIONAL
|
||||
|
||||
production:
|
||||
primary:
|
||||
url: <%= ENV['DATABASE_URL_PRIMARY'] %>
|
||||
encoding: utf8mb4
|
||||
collation: utf8mb4_unicode_520_ci
|
||||
variables:
|
||||
sql_mode: TRADITIONAL
|
||||
|
||||
openneo_id:
|
||||
url: <%= ENV['DATABASE_URL_OPENNEO_ID'] %>
|
||||
variables:
|
||||
sql_mode: TRADITIONAL
|
||||
migrations_paths: db/openneo_id_migrate
|
||||
url: <%= ENV['DATABASE_URL_PRIMARY'] %>
|
||||
encoding: utf8mb4
|
||||
collation: utf8mb4_unicode_520_ci
|
||||
variables:
|
||||
sql_mode: TRADITIONAL
|
||||
|
|
|
|||
|
|
@ -56,8 +56,7 @@ Rails.application.configure do
|
|||
# Highlight code that enqueued background job in logs.
|
||||
config.active_job.verbose_enqueue_logs = true
|
||||
|
||||
# Highlight code that triggered redirect in logs.
|
||||
config.action_dispatch.verbose_redirect_logs = true
|
||||
config.react.variant = :development
|
||||
|
||||
# Raises error for missing translations.
|
||||
# config.i18n.raise_on_missing_translations = true
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ Rails.application.configure do
|
|||
# Cache assets for far-future expiry since they are all digest stamped.
|
||||
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
|
||||
|
||||
# Enable static file serving from the `/public` folder (turn off if using NGINX/Apache for it).
|
||||
config.public_file_server.enabled = false
|
||||
|
||||
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
|
||||
# config.asset_host = "http://assets.example.com"
|
||||
|
||||
|
|
@ -34,7 +37,9 @@ Rails.application.configure do
|
|||
config.log_tags = [ :request_id ]
|
||||
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
|
||||
|
||||
# Change to "debug" to log everything (including potentially personally-identifiable information!).
|
||||
config.react.variant = :production
|
||||
|
||||
# Change to "debug" to log everything (including potentially personally-identifiable information!)
|
||||
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
|
||||
|
||||
# Prevent health checks from clogging up the logs.
|
||||
|
|
|
|||
|
|
@ -20,10 +20,6 @@
|
|||
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
|
||||
# config.content_security_policy_nonce_directives = %w(script-src style-src)
|
||||
#
|
||||
# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
|
||||
# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
|
||||
# # config.content_security_policy_nonce_auto = true
|
||||
#
|
||||
# # Report violations without enforcing the policy.
|
||||
# # config.content_security_policy_report_only = true
|
||||
# end
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
# Be sure to restart your server when you modify this file.
|
||||
#
|
||||
# This file eases your Rails 8.1 framework defaults upgrade.
|
||||
#
|
||||
# Uncomment each configuration one by one to switch to the new default.
|
||||
# Once your application is ready to run with all new defaults, you can remove
|
||||
# this file and set the `config.load_defaults` to `8.1`.
|
||||
#
|
||||
# Read the Guide for Upgrading Ruby on Rails for more info on each option.
|
||||
# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
|
||||
|
||||
###
|
||||
# Skips escaping HTML entities and line separators. When set to `false`, the
|
||||
# JSON renderer no longer escapes these to improve performance.
|
||||
#
|
||||
# Example:
|
||||
# class PostsController < ApplicationController
|
||||
# def index
|
||||
# render json: { key: "\u2028\u2029<>&" }
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# Renders `{"key":"\u2028\u2029\u003c\u003e\u0026"}` with the previous default, but `{"key":"
<>&"}` with the config
|
||||
# set to `false`.
|
||||
#
|
||||
# Applications that want to keep the escaping behavior can set the config to `true`.
|
||||
#++
|
||||
# Rails.configuration.action_controller.escape_json_responses = false
|
||||
|
||||
###
|
||||
# Skips escaping LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029) in JSON.
|
||||
#
|
||||
# Historically these characters were not valid inside JavaScript literal strings but that changed in ECMAScript 2019.
|
||||
# As such it's no longer a concern in modern browsers: https://caniuse.com/mdn-javascript_builtins_json_json_superset.
|
||||
#++
|
||||
# Rails.configuration.active_support.escape_js_separators_in_json = false
|
||||
|
||||
###
|
||||
# Raises an error when order dependent finder methods (e.g. `#first`, `#second`) are called without `order` values
|
||||
# on the relation, and the model does not have any order columns (`implicit_order_column`, `query_constraints`, or
|
||||
# `primary_key`) to fall back on.
|
||||
#
|
||||
# The current behavior of not raising an error has been deprecated, and this configuration option will be removed in
|
||||
# Rails 8.2.
|
||||
#++
|
||||
# Rails.configuration.active_record.raise_on_missing_required_finder_order_columns = true
|
||||
|
||||
###
|
||||
# Controls how Rails handles path relative URL redirects.
|
||||
# When set to `:raise`, Rails will raise an `ActionController::Redirecting::UnsafeRedirectError`
|
||||
# for relative URLs without a leading slash, which can help prevent open redirect vulnerabilities.
|
||||
#
|
||||
# Example:
|
||||
# redirect_to "example.com" # Raises UnsafeRedirectError
|
||||
# redirect_to "@attacker.com" # Raises UnsafeRedirectError
|
||||
# redirect_to "/safe/path" # Works correctly
|
||||
#
|
||||
# Applications that want to allow these redirects can set the config to `:log` (previous default)
|
||||
# to only log warnings, or `:notify` to send ActiveSupport notifications.
|
||||
#++
|
||||
# Rails.configuration.action_controller.action_on_path_relative_redirect = :raise
|
||||
|
||||
###
|
||||
# Use a Ruby parser to track dependencies between Action View templates
|
||||
#++
|
||||
# Rails.configuration.action_view.render_tracker = :ruby
|
||||
|
||||
###
|
||||
# When enabled, hidden inputs generated by `form_tag`, `token_tag`, `method_tag`, and the hidden parameter fields
|
||||
# included in `button_to` forms will omit the `autocomplete="off"` attribute.
|
||||
#
|
||||
# Applications that want to keep generating the `autocomplete` attribute for those tags can set it to `false`.
|
||||
#++
|
||||
# Rails.configuration.action_view.remove_hidden_field_autocomplete = true
|
||||
|
|
@ -640,11 +640,6 @@ en-MEEP:
|
|||
rank: Reep
|
||||
user: Meepit
|
||||
points: Peeps
|
||||
timeframes:
|
||||
all_time: All Meep
|
||||
this_year: Meeps Year
|
||||
this_month: Meeps Month
|
||||
this_week: Meeps Week
|
||||
|
||||
update:
|
||||
success: Settings successfully meeped.
|
||||
|
|
|
|||
|
|
@ -310,8 +310,7 @@ en:
|
|||
resources:
|
||||
jn_items: JN Items
|
||||
impress_2020: DTI 2020
|
||||
lebron: Lebron
|
||||
lebron_value: "Lebron: %{value}"
|
||||
lebron: "Lebron: %{value}"
|
||||
shop_wizard: Shop Wizard
|
||||
trading_post: Trades
|
||||
auction_genie: Auctions
|
||||
|
|
@ -783,11 +782,6 @@ en:
|
|||
rank: Rank
|
||||
user: User
|
||||
points: Points
|
||||
timeframes:
|
||||
all_time: All Time
|
||||
this_year: This Year
|
||||
this_month: This Month
|
||||
this_week: This Week
|
||||
|
||||
update:
|
||||
success: Settings successfully saved.
|
||||
|
|
|
|||
|
|
@ -505,11 +505,6 @@ es:
|
|||
rank: Puesto
|
||||
user: Usuario
|
||||
points: Puntos
|
||||
timeframes:
|
||||
all_time: Todo el Tiempo
|
||||
this_year: Este Año
|
||||
this_month: Este Mes
|
||||
this_week: Esta Semana
|
||||
update:
|
||||
success: Ajustes guardados correctamente.
|
||||
invalid: "No hemos podido guardar los ajustes: %{errors}"
|
||||
|
|
|
|||
|
|
@ -499,11 +499,6 @@ pt:
|
|||
rank: Rank
|
||||
user: Usuário
|
||||
points: Pontos
|
||||
timeframes:
|
||||
all_time: Todo o Tempo
|
||||
this_year: Este Ano
|
||||
this_month: Este Mês
|
||||
this_week: Esta Semana
|
||||
update:
|
||||
success: Configurações salvas com sucesso
|
||||
invalid: "Não foi possível salvar as configurações: %{errors}"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
class IncreaseUsernameLength < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
# NOTE: This is paired with a migration to the `openneo_id` database, too!
|
||||
# NOTE: This was originally paired with a migration to the legacy `openneo_id`
|
||||
# database (see db/openneo_id_migrate/20240401124406_increase_username_length.rb).
|
||||
# As of November 2025, the databases have been consolidated.
|
||||
reversible do |direction|
|
||||
direction.up {
|
||||
change_column :users, :name, :string, limit: 30, null: false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
class CopyAuthUsersTableToMainDatabase < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
# Create auth_users table in openneo_impress with same structure as openneo_id.users
|
||||
# This preserves all IDs, data, and constraints from the source table.
|
||||
create_table "auth_users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t|
|
||||
t.string "name", limit: 30, null: false
|
||||
t.string "encrypted_password", limit: 64
|
||||
t.string "email", limit: 50
|
||||
t.string "password_salt", limit: 32
|
||||
t.string "reset_password_token"
|
||||
t.integer "sign_in_count", default: 0
|
||||
t.datetime "current_sign_in_at", precision: nil
|
||||
t.datetime "last_sign_in_at", precision: nil
|
||||
t.string "current_sign_in_ip"
|
||||
t.string "last_sign_in_ip"
|
||||
t.integer "failed_attempts", default: 0
|
||||
t.string "unlock_token"
|
||||
t.datetime "locked_at", precision: nil
|
||||
t.datetime "created_at", precision: nil
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.datetime "reset_password_sent_at", precision: nil
|
||||
t.datetime "remember_created_at"
|
||||
t.string "provider"
|
||||
t.string "uid"
|
||||
t.string "neopass_email"
|
||||
end
|
||||
|
||||
# Add indexes (matching openneo_id.users schema)
|
||||
add_index "auth_users", ["email"], name: "index_auth_users_on_email", unique: true
|
||||
add_index "auth_users", ["provider", "uid"], name: "index_auth_users_on_provider_and_uid", unique: true
|
||||
add_index "auth_users", ["reset_password_token"], name: "index_auth_users_on_reset_password_token", unique: true
|
||||
add_index "auth_users", ["unlock_token"], name: "index_auth_users_on_unlock_token", unique: true
|
||||
|
||||
# Copy all data from openneo_id.users to openneo_impress.auth_users
|
||||
# This preserves all IDs so that User.remote_id continues to reference the correct AuthUser
|
||||
execute <<-SQL
|
||||
INSERT INTO openneo_impress.auth_users
|
||||
SELECT * FROM openneo_id.users
|
||||
SQL
|
||||
end
|
||||
|
||||
def down
|
||||
drop_table "auth_users"
|
||||
end
|
||||
end
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
class AddIndexToContributionsUserIdAndCreatedAt < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_index :contributions, [:user_id, :created_at],
|
||||
name: 'index_contributions_on_user_id_and_created_at'
|
||||
end
|
||||
end
|
||||
35
db/openneo_id_migrate/README.md
Normal file
35
db/openneo_id_migrate/README.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Legacy openneo_id Database Migrations
|
||||
|
||||
These migrations are kept for historical reference only. They were applied to the separate `openneo_id` database before it was consolidated into the main `openneo_impress` database in November 2025.
|
||||
|
||||
## What happened?
|
||||
|
||||
Originally, Dress to Impress used two separate MySQL databases:
|
||||
- `openneo_impress` - Main application data (items, outfits, closets, etc.)
|
||||
- `openneo_id` - Authentication data (user accounts, passwords, OAuth)
|
||||
|
||||
This split was a legacy from when "OpenNeo ID" was envisioned as a separate authentication service that would unify login across multiple OpenNeo projects. Since DTI was the only successful project, we consolidated the databases.
|
||||
|
||||
## Migration details
|
||||
|
||||
On **November 2, 2025**, the `openneo_id.users` table was copied to `openneo_impress.auth_users`, preserving all data and IDs. The `openneo_id` database was then removed from production.
|
||||
|
||||
See the main migrations directory for:
|
||||
- `20251102064247_copy_auth_users_table_to_main_database.rb` - The migration that copied the data
|
||||
|
||||
## Can these migrations be run?
|
||||
|
||||
**No.** These migrations reference the `openneo_id` database which no longer exists. They are preserved purely as documentation of how the authentication schema evolved over time.
|
||||
|
||||
## Migration history
|
||||
|
||||
1. `20230807005748_add_remember_created_at_to_users.rb` - Added Devise rememberable feature
|
||||
2. `20240313200849_add_omniauth_fields_to_users.rb` - Added NeoPass OAuth support
|
||||
3. `20240315020053_allow_null_email_and_password_for_users.rb` - Made email/password optional for OAuth users
|
||||
4. `20240401124406_increase_username_length.rb` - Increased username limit from 20 to 30 chars
|
||||
5. `20240407135246_add_neo_pass_email_to_users.rb` - Added neopass_email field
|
||||
6. `20240408120359_add_unique_index_for_omniauth_to_users.rb` - Added unique constraint for provider+uid
|
||||
|
||||
---
|
||||
|
||||
For current authentication schema, see `db/schema.rb` and look for the `auth_users` table.
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
# This file is auto-generated from the current state of the database. Instead
|
||||
# of editing this file, please use the migrations feature of Active Record to
|
||||
# incrementally modify your database, and then regenerate this schema definition.
|
||||
#
|
||||
# This file is the source Rails uses to define your schema when running `bin/rails
|
||||
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
||||
# be faster and is potentially less error prone than running all of your
|
||||
# migrations from scratch. Old migrations may fail to apply correctly if those
|
||||
# migrations use external dependencies or application code.
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2024_04_08_120359) do
|
||||
create_table "users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: nil
|
||||
t.datetime "current_sign_in_at", precision: nil
|
||||
t.string "current_sign_in_ip"
|
||||
t.string "email", limit: 50
|
||||
t.string "encrypted_password", limit: 64
|
||||
t.integer "failed_attempts", default: 0
|
||||
t.datetime "last_sign_in_at", precision: nil
|
||||
t.string "last_sign_in_ip"
|
||||
t.datetime "locked_at", precision: nil
|
||||
t.string "name", limit: 30, null: false
|
||||
t.string "neopass_email"
|
||||
t.string "password_salt", limit: 32
|
||||
t.string "provider"
|
||||
t.datetime "remember_created_at"
|
||||
t.datetime "reset_password_sent_at", precision: nil
|
||||
t.string "reset_password_token"
|
||||
t.integer "sign_in_count", default: 0
|
||||
t.string "uid"
|
||||
t.string "unlock_token"
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true
|
||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||
t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true
|
||||
end
|
||||
end
|
||||
236
db/schema.rb
236
db/schema.rb
|
|
@ -10,50 +10,77 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_01_21_031001) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_11_02_064247) do
|
||||
create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "body_id", null: false
|
||||
t.integer "color_id", null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.string "full_name"
|
||||
t.string "series_name"
|
||||
t.integer "species_id", null: false
|
||||
t.string "thumbnail_url", null: false
|
||||
t.integer "color_id", null: false
|
||||
t.integer "body_id", null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
t.string "series_name"
|
||||
t.string "thumbnail_url", null: false
|
||||
t.string "full_name"
|
||||
t.index ["color_id"], name: "index_alt_styles_on_color_id"
|
||||
t.index ["species_id"], name: "index_alt_styles_on_species_id"
|
||||
end
|
||||
|
||||
create_table "auth_servers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.text "gateway", size: :long, null: false
|
||||
t.text "icon", size: :long, null: false
|
||||
t.string "name", limit: 40, null: false
|
||||
t.string "secret", limit: 64, null: false
|
||||
t.string "short_name", limit: 10, null: false
|
||||
t.string "name", limit: 40, null: false
|
||||
t.text "icon", size: :long, null: false
|
||||
t.text "gateway", size: :long, null: false
|
||||
t.string "secret", limit: 64, null: false
|
||||
end
|
||||
|
||||
create_table "auth_users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t|
|
||||
t.string "name", limit: 30, null: false
|
||||
t.string "encrypted_password", limit: 64
|
||||
t.string "email", limit: 50
|
||||
t.string "password_salt", limit: 32
|
||||
t.string "reset_password_token"
|
||||
t.integer "sign_in_count", default: 0
|
||||
t.datetime "current_sign_in_at", precision: nil
|
||||
t.datetime "last_sign_in_at", precision: nil
|
||||
t.string "current_sign_in_ip"
|
||||
t.string "last_sign_in_ip"
|
||||
t.integer "failed_attempts", default: 0
|
||||
t.string "unlock_token"
|
||||
t.datetime "locked_at", precision: nil
|
||||
t.datetime "created_at", precision: nil
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.datetime "reset_password_sent_at", precision: nil
|
||||
t.datetime "remember_created_at"
|
||||
t.string "provider"
|
||||
t.string "uid"
|
||||
t.string "neopass_email"
|
||||
t.index ["email"], name: "index_auth_users_on_email", unique: true
|
||||
t.index ["provider", "uid"], name: "index_auth_users_on_provider_and_uid", unique: true
|
||||
t.index ["reset_password_token"], name: "index_auth_users_on_reset_password_token", unique: true
|
||||
t.index ["unlock_token"], name: "index_auth_users_on_unlock_token", unique: true
|
||||
end
|
||||
|
||||
create_table "campaigns", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.boolean "active", null: false
|
||||
t.boolean "advertised", default: true, null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.text "description", size: :long, null: false
|
||||
t.integer "goal", null: false
|
||||
t.string "name"
|
||||
t.integer "progress", default: 0, null: false
|
||||
t.string "purpose", default: "our hosting costs this year", null: false
|
||||
t.text "thanks", size: :long
|
||||
t.string "theme_id", default: "hug", null: false
|
||||
t.integer "goal", null: false
|
||||
t.boolean "active", null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
t.boolean "advertised", default: true, null: false
|
||||
t.text "description", size: :long, null: false
|
||||
t.string "purpose", default: "our hosting costs this year", null: false
|
||||
t.string "theme_id", default: "hug", null: false
|
||||
t.text "thanks", size: :long
|
||||
t.string "name"
|
||||
end
|
||||
|
||||
create_table "closet_hangers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: nil
|
||||
t.integer "item_id"
|
||||
t.integer "list_id"
|
||||
t.boolean "owned", default: true, null: false
|
||||
t.integer "quantity"
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.integer "user_id"
|
||||
t.integer "quantity"
|
||||
t.datetime "created_at", precision: nil
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.boolean "owned", default: true, null: false
|
||||
t.integer "list_id"
|
||||
t.index ["item_id", "owned"], name: "index_closet_hangers_on_item_id_and_owned"
|
||||
t.index ["list_id"], name: "index_closet_hangers_on_list_id"
|
||||
t.index ["user_id", "list_id", "item_id", "owned", "created_at"], name: "index_closet_hangers_test_20131226"
|
||||
|
|
@ -63,85 +90,84 @@ ActiveRecord::Schema[8.1].define(version: 2026_01_21_031001) do
|
|||
end
|
||||
|
||||
create_table "closet_lists", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: nil
|
||||
t.text "description", size: :long
|
||||
t.boolean "hangers_owned", null: false
|
||||
t.string "name"
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.text "description", size: :long
|
||||
t.integer "user_id"
|
||||
t.boolean "hangers_owned", null: false
|
||||
t.datetime "created_at", precision: nil
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.integer "visibility", default: 1, null: false
|
||||
t.index ["user_id"], name: "index_closet_lists_on_user_id"
|
||||
end
|
||||
|
||||
create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.boolean "basic"
|
||||
t.boolean "standard"
|
||||
t.string "name", null: false
|
||||
t.string "pb_item_name"
|
||||
t.string "pb_item_thumbnail_url"
|
||||
t.boolean "standard"
|
||||
end
|
||||
|
||||
create_table "contributions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "contributed_id", null: false
|
||||
t.string "contributed_type", limit: 8, null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.integer "contributed_id", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.index ["contributed_id", "contributed_type"], name: "index_contributions_on_contributed_id_and_contributed_type"
|
||||
t.index ["user_id", "created_at"], name: "index_contributions_on_user_id_and_created_at"
|
||||
t.index ["user_id"], name: "index_contributions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "donation_features", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.integer "donation_id", null: false
|
||||
t.integer "outfit_id"
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
end
|
||||
|
||||
create_table "donations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "amount", null: false
|
||||
t.integer "campaign_id", null: false
|
||||
t.string "charge_id", null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.string "donor_email"
|
||||
t.integer "user_id"
|
||||
t.string "donor_name"
|
||||
t.string "secret"
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
t.integer "user_id"
|
||||
t.string "donor_email"
|
||||
t.integer "campaign_id", null: false
|
||||
end
|
||||
|
||||
create_table "item_outfit_relationships", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: nil
|
||||
t.boolean "is_worn"
|
||||
t.integer "item_id"
|
||||
t.integer "outfit_id"
|
||||
t.boolean "is_worn"
|
||||
t.datetime "created_at", precision: nil
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.index ["item_id"], name: "index_item_outfit_relationships_on_item_id"
|
||||
t.index ["outfit_id", "is_worn"], name: "index_item_outfit_relationships_on_outfit_id_and_is_worn"
|
||||
end
|
||||
|
||||
create_table "items", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.text "cached_compatible_body_ids", default: ""
|
||||
t.string "cached_occupied_zone_ids", default: ""
|
||||
t.boolean "cached_predicted_fully_modeled", default: false, null: false
|
||||
t.text "zones_restrict", size: :medium, null: false
|
||||
t.text "thumbnail_url", size: :long, null: false
|
||||
t.string "category", limit: 50
|
||||
t.string "type", limit: 50
|
||||
t.integer "rarity_index", limit: 2
|
||||
t.integer "price", limit: 3, null: false
|
||||
t.integer "weight_lbs", limit: 2
|
||||
t.text "species_support_ids", size: :long
|
||||
t.datetime "created_at", precision: nil
|
||||
t.text "description", size: :medium, null: false
|
||||
t.integer "dyeworks_base_item_id"
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.boolean "explicitly_body_specific", default: false, null: false
|
||||
t.boolean "is_manually_nc", default: false, null: false
|
||||
t.integer "manual_special_color_id"
|
||||
t.column "modeling_status_hint", "enum('done','glitchy')"
|
||||
t.boolean "is_manually_nc", default: false, null: false
|
||||
t.string "name", null: false
|
||||
t.integer "price", limit: 3, null: false
|
||||
t.text "description", size: :medium, null: false
|
||||
t.string "rarity", default: "", null: false
|
||||
t.integer "rarity_index", limit: 2
|
||||
t.text "species_support_ids", size: :long
|
||||
t.text "thumbnail_url", size: :long, null: false
|
||||
t.string "type", limit: 50
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.integer "weight_lbs", limit: 2
|
||||
t.text "zones_restrict", size: :medium, null: false
|
||||
t.integer "dyeworks_base_item_id"
|
||||
t.string "cached_occupied_zone_ids", default: ""
|
||||
t.text "cached_compatible_body_ids", default: ""
|
||||
t.boolean "cached_predicted_fully_modeled", default: false, null: false
|
||||
t.index ["dyeworks_base_item_id"], name: "index_items_on_dyeworks_base_item_id"
|
||||
t.index ["modeling_status_hint", "created_at", "id"], name: "items_modeling_status_hint_and_created_at_and_id"
|
||||
t.index ["modeling_status_hint", "created_at"], name: "items_modeling_status_hint_and_created_at"
|
||||
|
|
@ -151,9 +177,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_01_21_031001) do
|
|||
end
|
||||
|
||||
create_table "login_cookies", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "user_id", null: false
|
||||
t.integer "series", null: false
|
||||
t.integer "token", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.index ["user_id", "series"], name: "login_cookies_user_id_and_series"
|
||||
t.index ["user_id"], name: "login_cookies_user_id"
|
||||
end
|
||||
|
|
@ -165,34 +191,34 @@ ActiveRecord::Schema[8.1].define(version: 2026_01_21_031001) do
|
|||
end
|
||||
|
||||
create_table "nc_mall_records", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "discount_begins_at"
|
||||
t.datetime "discount_ends_at"
|
||||
t.integer "discount_price"
|
||||
t.integer "item_id", null: false
|
||||
t.integer "price", null: false
|
||||
t.integer "discount_price"
|
||||
t.datetime "discount_begins_at"
|
||||
t.datetime "discount_ends_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["item_id"], name: "index_nc_mall_records_on_item_id", unique: true
|
||||
end
|
||||
|
||||
create_table "neopets_connections", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.string "neopets_username"
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
t.integer "user_id"
|
||||
t.string "neopets_username"
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
end
|
||||
|
||||
create_table "outfits", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.bigint "alt_style_id"
|
||||
t.datetime "created_at", precision: nil
|
||||
t.string "image"
|
||||
t.boolean "image_enqueued", default: false, null: false
|
||||
t.string "image_layers_hash"
|
||||
t.string "name"
|
||||
t.integer "pet_state_id"
|
||||
t.boolean "starred", default: false, null: false
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.integer "user_id"
|
||||
t.datetime "created_at", precision: nil
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.string "name"
|
||||
t.boolean "starred", default: false, null: false
|
||||
t.string "image"
|
||||
t.string "image_layers_hash"
|
||||
t.boolean "image_enqueued", default: false, null: false
|
||||
t.bigint "alt_style_id"
|
||||
t.index ["alt_style_id"], name: "index_outfits_on_alt_style_id"
|
||||
t.index ["pet_state_id"], name: "index_outfits_on_pet_state_id"
|
||||
t.index ["user_id"], name: "index_outfits_on_user_id"
|
||||
|
|
@ -200,40 +226,40 @@ ActiveRecord::Schema[8.1].define(version: 2026_01_21_031001) do
|
|||
|
||||
create_table "parents_swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "parent_id", null: false
|
||||
t.string "parent_type", limit: 8, null: false
|
||||
t.integer "swf_asset_id", null: false
|
||||
t.string "parent_type", limit: 8, null: false
|
||||
t.index ["parent_id", "parent_type"], name: "index_parents_swf_assets_on_parent_id_and_parent_type"
|
||||
t.index ["parent_id", "swf_asset_id"], name: "unique_parents_swf_assets", unique: true
|
||||
t.index ["swf_asset_id"], name: "parents_swf_assets_swf_asset_id"
|
||||
end
|
||||
|
||||
create_table "pet_loads", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.string "pet_name", limit: 20, null: false
|
||||
t.text "amf", size: :long, null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.string "pet_name", limit: 20, null: false
|
||||
end
|
||||
|
||||
create_table "pet_states", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.string "artist_neopets_username"
|
||||
t.datetime "created_at"
|
||||
t.boolean "female"
|
||||
t.boolean "glitched", default: false, null: false
|
||||
t.boolean "labeled", default: false, null: false
|
||||
t.integer "mood_id"
|
||||
t.integer "pet_type_id", null: false
|
||||
t.text "swf_asset_ids", size: :medium, null: false
|
||||
t.boolean "female"
|
||||
t.integer "mood_id"
|
||||
t.boolean "unconverted"
|
||||
t.boolean "labeled", default: false, null: false
|
||||
t.boolean "glitched", default: false, null: false
|
||||
t.string "artist_neopets_username"
|
||||
t.datetime "created_at"
|
||||
t.datetime "updated_at"
|
||||
t.index ["pet_type_id"], name: "pet_states_pet_type_id"
|
||||
end
|
||||
|
||||
create_table "pet_types", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.string "basic_image_hash"
|
||||
t.integer "body_id", limit: 2, null: false
|
||||
t.integer "color_id", null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.string "image_hash", limit: 8
|
||||
t.integer "species_id", null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.integer "body_id", limit: 2, null: false
|
||||
t.string "image_hash", limit: 8
|
||||
t.string "basic_image_hash"
|
||||
t.index ["body_id", "color_id", "species_id"], name: "pet_types_body_id_and_color_id_and_species_id"
|
||||
t.index ["body_id"], name: "pet_types_body_id"
|
||||
t.index ["color_id", "species_id"], name: "pet_types_color_id_and_species_id"
|
||||
|
|
@ -253,50 +279,50 @@ ActiveRecord::Schema[8.1].define(version: 2026_01_21_031001) do
|
|||
end
|
||||
|
||||
create_table "swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "body_id", limit: 2, null: false
|
||||
t.datetime "converted_at", precision: nil
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.boolean "has_image", default: false, null: false
|
||||
t.boolean "image_manual", default: false, null: false
|
||||
t.boolean "image_requested", default: false, null: false
|
||||
t.string "known_glitches", limit: 128, default: ""
|
||||
t.text "manifest", size: :long
|
||||
t.timestamp "manifest_cached_at"
|
||||
t.datetime "manifest_loaded_at"
|
||||
t.integer "manifest_status_code"
|
||||
t.string "manifest_url"
|
||||
t.integer "remote_id", limit: 3, null: false
|
||||
t.datetime "reported_broken_at", precision: nil
|
||||
t.string "type", limit: 7, null: false
|
||||
t.integer "remote_id", limit: 3, null: false
|
||||
t.text "url", size: :long, null: false
|
||||
t.integer "zone_id", null: false
|
||||
t.text "zones_restrict", size: :medium, null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.integer "body_id", limit: 2, null: false
|
||||
t.boolean "has_image", default: false, null: false
|
||||
t.boolean "image_requested", default: false, null: false
|
||||
t.datetime "reported_broken_at", precision: nil
|
||||
t.datetime "converted_at", precision: nil
|
||||
t.boolean "image_manual", default: false, null: false
|
||||
t.text "manifest", size: :long
|
||||
t.timestamp "manifest_cached_at"
|
||||
t.string "known_glitches", limit: 128, default: ""
|
||||
t.string "manifest_url"
|
||||
t.datetime "manifest_loaded_at"
|
||||
t.integer "manifest_status_code"
|
||||
t.index ["body_id"], name: "swf_assets_body_id_and_object_id"
|
||||
t.index ["type", "remote_id"], name: "swf_assets_type_and_id"
|
||||
t.index ["zone_id"], name: "idx_swf_assets_zone_id"
|
||||
end
|
||||
|
||||
create_table "users", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.string "name", limit: 30, null: false
|
||||
t.integer "auth_server_id", limit: 1, null: false
|
||||
t.integer "remote_id", null: false
|
||||
t.integer "points", default: 0, null: false
|
||||
t.boolean "beta", default: false, null: false
|
||||
t.string "remember_token"
|
||||
t.datetime "remember_created_at", precision: nil
|
||||
t.integer "owned_closet_hangers_visibility", default: 1, null: false
|
||||
t.integer "wanted_closet_hangers_visibility", default: 1, null: false
|
||||
t.integer "contact_neopets_connection_id"
|
||||
t.timestamp "last_trade_activity_at"
|
||||
t.string "name", limit: 30, null: false
|
||||
t.integer "owned_closet_hangers_visibility", default: 1, null: false
|
||||
t.integer "points", default: 0, null: false
|
||||
t.datetime "remember_created_at", precision: nil
|
||||
t.string "remember_token"
|
||||
t.integer "remote_id", null: false
|
||||
t.boolean "shadowbanned", default: false, null: false
|
||||
t.boolean "support_staff", default: false, null: false
|
||||
t.integer "wanted_closet_hangers_visibility", default: 1, null: false
|
||||
t.boolean "shadowbanned", default: false, null: false
|
||||
end
|
||||
|
||||
create_table "zones", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "depth"
|
||||
t.integer "type_id"
|
||||
t.string "label", null: false
|
||||
t.string "plain_label", null: false
|
||||
t.integer "type_id"
|
||||
end
|
||||
|
||||
add_foreign_key "alt_styles", "colors"
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
# Sample contributions for testing Top Contributors feature
|
||||
# Run with: rails runner db/seeds/top_contributors_sample_data.rb
|
||||
|
||||
puts "Creating sample contributions for Top Contributors testing..."
|
||||
|
||||
# Find or create test users
|
||||
users = []
|
||||
5.times do |i|
|
||||
name = "TestContributor#{i + 1}"
|
||||
user = User.find_or_create_by!(name: name) do |u|
|
||||
# Create a corresponding auth_user record
|
||||
auth_user = AuthUser.create!(
|
||||
name: name,
|
||||
email: "test#{i + 1}@example.com",
|
||||
password: 'password123',
|
||||
)
|
||||
u.remote_id = auth_user.id
|
||||
u.auth_server_id = 1
|
||||
end
|
||||
users << user
|
||||
end
|
||||
|
||||
# Get some existing items/pet types to contribute
|
||||
items = Item.limit(10).to_a
|
||||
pet_types = PetType.limit(5).to_a
|
||||
swf_assets = SwfAsset.limit(5).to_a
|
||||
|
||||
if items.empty? || pet_types.empty?
|
||||
puts "WARNING: No items or pet types found. Create some first or contributions will be limited."
|
||||
end
|
||||
|
||||
# Create contributions with different time periods
|
||||
# User 1: Heavy contributor this week
|
||||
if items.any?
|
||||
3.times { Contribution.create!(user: users[0], contributed: items.sample, created_at: 2.days.ago) }
|
||||
5.times { Contribution.create!(user: users[0], contributed: items.sample, created_at: 5.days.ago) }
|
||||
end
|
||||
|
||||
# User 2: Heavy contributor this month, but not this week
|
||||
if items.any? && pet_types.any?
|
||||
2.times { Contribution.create!(user: users[1], contributed: items.sample, created_at: 15.days.ago) }
|
||||
1.times { Contribution.create!(user: users[1], contributed: pet_types.sample, created_at: 20.days.ago) }
|
||||
end
|
||||
|
||||
# User 3: Heavy contributor this year, but not this month
|
||||
if pet_types.any?
|
||||
3.times { Contribution.create!(user: users[2], contributed: pet_types.sample, created_at: 3.months.ago) }
|
||||
end
|
||||
|
||||
# User 4: Old contributor (only in all-time)
|
||||
if items.any?
|
||||
users[3].update!(points: 500) # Set points directly for all-time view
|
||||
2.times { Contribution.create!(user: users[3], contributed: items.sample, created_at: 2.years.ago) }
|
||||
end
|
||||
|
||||
# User 5: Mixed contributions across all periods
|
||||
if items.any? && pet_types.any?
|
||||
Contribution.create!(user: users[4], contributed: items.sample, created_at: 1.day.ago)
|
||||
Contribution.create!(user: users[4], contributed: pet_types.sample, created_at: 10.days.ago)
|
||||
Contribution.create!(user: users[4], contributed: items.sample, created_at: 2.months.ago)
|
||||
end
|
||||
if swf_assets.any?
|
||||
Contribution.create!(user: users[4], contributed: swf_assets.sample, created_at: 4.days.ago)
|
||||
end
|
||||
|
||||
puts "Created sample contributions:"
|
||||
puts "- #{users[0].name}: #{users[0].contributions.count} contributions (focus: this week)"
|
||||
puts "- #{users[1].name}: #{users[1].contributions.count} contributions (focus: this month)"
|
||||
puts "- #{users[2].name}: #{users[2].contributions.count} contributions (focus: this year)"
|
||||
puts "- #{users[3].name}: #{users[3].contributions.count} contributions (focus: all-time, #{users[3].points} points)"
|
||||
puts "- #{users[4].name}: #{users[4].contributions.count} contributions (mixed periods)"
|
||||
puts "\nTest the feature at: http://localhost:3000/users/top-contributors"
|
||||
|
|
@ -191,7 +191,6 @@
|
|||
name:
|
||||
- libmysqlclient-dev
|
||||
- libyaml-dev
|
||||
- libvips-dev
|
||||
|
||||
- name: Create the app folder
|
||||
file:
|
||||
|
|
@ -420,19 +419,18 @@
|
|||
community.mysql.mysql_db:
|
||||
name:
|
||||
- openneo_impress
|
||||
- openneo_id
|
||||
|
||||
- name: Create MySQL user openneo_impress
|
||||
community.mysql.mysql_user:
|
||||
name: openneo_impress
|
||||
password: "{{ mysql_user_password }}"
|
||||
priv: "openneo_impress.*:ALL,openneo_id.*:ALL"
|
||||
priv: "openneo_impress.*:ALL"
|
||||
|
||||
- name: Create MySQL user impress2020
|
||||
community.mysql.mysql_user:
|
||||
name: impress2020
|
||||
password: "{{ mysql_user_password_2020 }}"
|
||||
priv: "openneo_impress.*:ALL,openneo_id.*:ALL"
|
||||
priv: "openneo_impress.*:ALL"
|
||||
|
||||
- name: Create the Neopets Media Archive data directory
|
||||
file:
|
||||
|
|
|
|||
|
|
@ -249,28 +249,6 @@ This crowdsourced approach is why DTI is "self-sustaining" - users passively con
|
|||
|
||||
See `app/models/pet/modeling_snapshot.rb` for the full implementation.
|
||||
|
||||
### Potential Upgrade: Auto-Modeling
|
||||
|
||||
We are currently not making full use of a recently-discovered Neopets feature: **we no longer need a real pet to model
|
||||
the item**. There's an NC Mall feature that supports previews of *any* item, not just those sold in the Mall.
|
||||
|
||||
See the `pets:load` task for implementation details.
|
||||
|
||||
We still need users to show us new items in the first place, to learn their item IDs and what body types they might
|
||||
fit. But once we have that, we can proactively attempt to model the pet on all relevant body types.
|
||||
|
||||
Let's pursue this in two steps:
|
||||
|
||||
1. [x] Create a backfill Rake task to attempt to load any models we suspect we need, based on the same logic as the
|
||||
`-is:modeled` item search filter.
|
||||
- Consider having this task auto-update the `modeling_status_hint` field on the item, if we've demonstrated that
|
||||
the item is almost certainly completely modeled, despite our heuristic indicating it is not. This will keep the
|
||||
`is:modeled` filter clean and approximately empty.
|
||||
- As part of this, let's refactor the logic out of `pets:load`, to more simply construct an "image hash" ("sci")
|
||||
from a pet type + items combination.
|
||||
2. [ ] Set this as a cron job to run very frequently, to quickly load in new items.
|
||||
- If we're able to reliably keep `is:modeled` basically empty, this could even be safe to run every, say, 2–5min.
|
||||
|
||||
### Cached Fields
|
||||
|
||||
To avoid expensive queries, several models cache computed data:
|
||||
|
|
|
|||
287
docs/database-consolidation-deployment.md
Normal file
287
docs/database-consolidation-deployment.md
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
# Database Consolidation Deployment Guide
|
||||
|
||||
This document outlines the plan and checklist for consolidating the `openneo_id` database into the main `openneo_impress` database.
|
||||
|
||||
## Current Status: BLOCKED
|
||||
|
||||
**This migration cannot be deployed until Impress 2020 is retired.**
|
||||
|
||||
## The Problem
|
||||
|
||||
While the main DTI Rails app is ready to move to a single-database architecture, **Impress 2020 still directly accesses both databases**:
|
||||
|
||||
- `openneo_impress` - For reading item, pet, and outfit data
|
||||
- `openneo_id` - For user authentication via GraphQL
|
||||
|
||||
If we consolidate the databases now, Impress 2020's authentication will break immediately, causing login failures for users accessing DTI through the Impress 2020 GraphQL API.
|
||||
|
||||
## Path Forward
|
||||
|
||||
There are two options to unblock this migration:
|
||||
|
||||
### Option A: Retire Impress 2020 First (Recommended)
|
||||
|
||||
1. Complete the migration of remaining Impress 2020 dependencies back to the main Rails app
|
||||
- See `docs/impress-2020-dependencies.md` for current status
|
||||
- Primary remaining dependencies: GraphQL API for outfit data, image generation service
|
||||
2. Spin down the Impress 2020 service entirely
|
||||
3. Execute the database consolidation (steps below)
|
||||
|
||||
### Option B: Coordinated Update (Complex)
|
||||
|
||||
1. Update Impress 2020 to point to `openneo_impress.auth_users` instead of `openneo_id.users`
|
||||
2. Deploy both applications simultaneously during a maintenance window
|
||||
3. Execute the database consolidation
|
||||
|
||||
**Recommendation:** Option A is simpler and aligns with our long-term goal of fully consolidating back into the Rails monolith.
|
||||
|
||||
---
|
||||
|
||||
## Deployment Checklist (When Ready)
|
||||
|
||||
⚠️ **DO NOT EXECUTE UNTIL IMPRESS 2020 IS RETIRED**
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [ ] Impress 2020 service is spun down and no longer accessing databases
|
||||
- [ ] All Impress 2020 dependencies have been migrated to main Rails app
|
||||
- [ ] Database backups are current and tested
|
||||
- [ ] Maintenance window scheduled (estimate: 30-60 minutes)
|
||||
|
||||
### Phase 1: Deploy Write Lock
|
||||
|
||||
**Branch:** `feature/consolidate-auth-database` @ commit `604a8667`
|
||||
|
||||
**Purpose:** Prevent writes to AuthUser table while keeping login/logout functional.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Deploy Phase 1 to production
|
||||
2. Verify:
|
||||
- [ ] Existing users can log in
|
||||
- [ ] Existing users can log out
|
||||
- [ ] Registration shows maintenance message
|
||||
- [ ] Settings updates show maintenance message
|
||||
- [ ] NeoPass connection shows maintenance message
|
||||
|
||||
**Expected Downtime:** None (read-only mode for account changes only)
|
||||
|
||||
### Phase 2: Copy Data
|
||||
|
||||
**Purpose:** Copy auth data from `openneo_id` to `openneo_impress` while table is stable.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. **Backup openneo_id database:**
|
||||
```bash
|
||||
mysqldump -h [host] -u [user] -p openneo_id > openneo_id_backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
```
|
||||
|
||||
2. **Verify backup:**
|
||||
```bash
|
||||
# Check file size is reasonable
|
||||
ls -lh openneo_id_backup_*.sql
|
||||
|
||||
# Spot-check contents
|
||||
head -n 50 openneo_id_backup_*.sql
|
||||
```
|
||||
|
||||
3. **Run the migration:**
|
||||
```bash
|
||||
cd /var/www/impress
|
||||
bundle exec rails db:migrate
|
||||
```
|
||||
|
||||
4. **Verify data copy:**
|
||||
```sql
|
||||
-- Connect to MySQL
|
||||
mysql -h [host] -u [user] -p
|
||||
|
||||
-- Check row counts match
|
||||
SELECT COUNT(*) AS openneo_id_count FROM openneo_id.users;
|
||||
SELECT COUNT(*) AS auth_users_count FROM openneo_impress.auth_users;
|
||||
|
||||
-- Spot-check a few records
|
||||
SELECT id, name, email FROM openneo_id.users LIMIT 5;
|
||||
SELECT id, name, email FROM openneo_impress.auth_users WHERE id IN (1, 2, 3, 4, 5);
|
||||
|
||||
-- Verify indexes were created
|
||||
SHOW INDEX FROM openneo_impress.auth_users;
|
||||
```
|
||||
|
||||
5. **Verify results:**
|
||||
- [ ] Row counts match exactly
|
||||
- [ ] Sample records match (IDs, names, emails)
|
||||
- [ ] All 4 indexes created (email, provider+uid, reset_password_token, unlock_token)
|
||||
|
||||
**Expected Downtime:** None (still in write-lock mode)
|
||||
|
||||
### Phase 3: Switch to New Table
|
||||
|
||||
**Branch:** `feature/consolidate-auth-database` @ commit `2c21269a`
|
||||
|
||||
**Purpose:** Point AuthUser at consolidated table, restore full functionality.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Deploy Phase 2 to production
|
||||
2. **Immediately test critical paths:**
|
||||
- [ ] Login with existing account
|
||||
- [ ] Logout
|
||||
- [ ] Register new account
|
||||
- [ ] Update account settings (email, password)
|
||||
- [ ] Connect NeoPass (if available)
|
||||
- [ ] Disconnect NeoPass (if available)
|
||||
|
||||
3. **Monitor error logs:**
|
||||
```bash
|
||||
tail -f /var/www/impress/log/production.log | grep -i error
|
||||
```
|
||||
|
||||
4. **Verify database queries are using auth_users:**
|
||||
```bash
|
||||
# Check recent queries in logs
|
||||
grep "auth_users" /var/www/impress/log/production.log | tail -n 20
|
||||
|
||||
# Should see SELECT/INSERT/UPDATE on auth_users, NOT openneo_id.users
|
||||
```
|
||||
|
||||
**Expected Downtime:** Brief (< 1 minute for deployment)
|
||||
|
||||
**Rollback Plan:** If critical issues found, revert to Phase 1 commit and restore openneo_id from backup.
|
||||
|
||||
### Phase 4: Documentation Update
|
||||
|
||||
**Branch:** `feature/consolidate-auth-database` @ commit `9ba94f9f`
|
||||
|
||||
**Purpose:** Update documentation to reflect single-database architecture.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Deploy Phase 3 to production
|
||||
2. Verify no errors
|
||||
|
||||
**Expected Downtime:** None
|
||||
|
||||
### Phase 5: Database Teardown
|
||||
|
||||
**Purpose:** Remove the now-unused `openneo_id` database.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. **Wait 7 days** to ensure no issues found in production
|
||||
|
||||
2. **Final backup:**
|
||||
```bash
|
||||
mysqldump -h [host] -u [user] -p openneo_id > openneo_id_final_backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
```
|
||||
|
||||
3. **Store backup offsite:**
|
||||
- Upload to secure backup storage
|
||||
- Keep for at least 90 days
|
||||
|
||||
4. **Drop the database:**
|
||||
```sql
|
||||
DROP DATABASE openneo_id;
|
||||
```
|
||||
|
||||
5. **Remove environment variable:**
|
||||
- Delete `DATABASE_URL_OPENNEO_ID` from production environment config
|
||||
- Restart app to ensure it doesn't try to connect
|
||||
|
||||
6. **Update MySQL users:**
|
||||
```sql
|
||||
-- Remove openneo_id privileges from users
|
||||
-- (Already done in deploy/setup.yml for new deployments)
|
||||
```
|
||||
|
||||
**Expected Downtime:** None
|
||||
|
||||
---
|
||||
|
||||
## Rollback Procedures
|
||||
|
||||
### If Issues Found After Phase 3
|
||||
|
||||
1. **Immediate rollback:**
|
||||
```bash
|
||||
# Revert to Phase 1 commit
|
||||
git checkout 604a8667
|
||||
bundle exec rails db:migrate:down VERSION=20251102064247
|
||||
# Deploy
|
||||
```
|
||||
|
||||
2. **Restore openneo_id (if needed):**
|
||||
```bash
|
||||
mysql -h [host] -u [user] -p openneo_id < openneo_id_backup_[timestamp].sql
|
||||
```
|
||||
|
||||
3. **Investigate issues before reattempting**
|
||||
|
||||
### If Data Corruption Detected
|
||||
|
||||
1. **Immediately restore from backup:**
|
||||
```bash
|
||||
# Drop corrupted auth_users table
|
||||
mysql -h [host] -u [user] -p -e "DROP TABLE openneo_impress.auth_users;"
|
||||
|
||||
# Restore openneo_id if needed
|
||||
mysql -h [host] -u [user] -p openneo_id < openneo_id_backup_[timestamp].sql
|
||||
```
|
||||
|
||||
2. **Revert to pre-migration code**
|
||||
3. **Review migration SQL before reattempting**
|
||||
|
||||
---
|
||||
|
||||
## Key Risks & Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation | Status |
|
||||
|------|--------|------------|--------|
|
||||
| Impress 2020 auth breaks | HIGH - Users can't log in via I2020 | Block deployment until I2020 retired | ⚠️ BLOCKING |
|
||||
| Data copy fails mid-migration | HIGH - Incomplete auth data | Wrapped in transaction, can rollback | ✅ Mitigated |
|
||||
| Production traffic during copy | MEDIUM - Stale data | Write lock prevents changes | ✅ Mitigated |
|
||||
| Schema mismatch between DBs | MEDIUM - Migration fails | Migration matches exact schema | ✅ Mitigated |
|
||||
| Indexes not created | MEDIUM - Slow queries | Verification step checks indexes | ✅ Mitigated |
|
||||
| Login tracking data loss | LOW - Missing login stats | Acceptable trade-off | ✅ Accepted |
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] All existing users can log in
|
||||
- [ ] New user registration works
|
||||
- [ ] Settings updates work
|
||||
- [ ] NeoPass connection/disconnection works
|
||||
- [ ] No errors in production logs
|
||||
- [ ] Query performance unchanged
|
||||
- [ ] Database row counts match
|
||||
- [ ] All auth_users indexes present
|
||||
|
||||
---
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
**Total time:** 30-60 minutes (after Impress 2020 retired)
|
||||
|
||||
- Phase 1 deployment: 5 min
|
||||
- Phase 2 data copy: 5-10 min (depending on user count)
|
||||
- Phase 3 deployment + testing: 15-30 min
|
||||
- Phase 4 deployment: 5 min
|
||||
- Phase 5 teardown: 7+ days later, 10 min
|
||||
|
||||
---
|
||||
|
||||
## Questions Before Proceeding
|
||||
|
||||
1. **Is Impress 2020 fully retired?** If not, STOP.
|
||||
2. Do we have recent database backups? (< 24 hours old)
|
||||
3. Do we have a maintenance window scheduled?
|
||||
4. Have we announced the maintenance to users?
|
||||
5. Do we have rollback access ready?
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** November 2025
|
||||
**Status:** Blocked on Impress 2020 retirement
|
||||
**Branch:** `feature/consolidate-auth-database`
|
||||
|
|
@ -135,33 +135,14 @@ This is the most complex migration:
|
|||
|
||||
- **Main Rails app**: Primary VPS server, serves web traffic and API
|
||||
- **Impress 2020**: Separate VPS in same datacenter, provides GraphQL API and image services
|
||||
- **Databases**: Two MySQL databases on main Rails server, **both accessed directly by Impress 2020**:
|
||||
- `openneo_impress` - Main application data (items, pets, outfits, etc.)
|
||||
- `openneo_id` - Authentication data (user accounts, passwords, OAuth)
|
||||
|
||||
**CRITICAL**: Impress 2020 directly queries both databases via SQL. Any database consolidation must wait until Impress 2020 is retired, or both services must be updated in a coordinated deployment.
|
||||
- **Database**: MySQL on main Rails server, accessed by both services
|
||||
- **OpenNeo ID database**: Separate MySQL database (legacy, could be merged)
|
||||
|
||||
### After Full Migration
|
||||
|
||||
- **Single Rails app**: One VPS serving everything
|
||||
- **Image service**: Either integrated into Rails or extracted as a simple microservice
|
||||
- **Single MySQL database**: Can merge `openneo_id` into `openneo_impress` once Impress 2020 is retired
|
||||
- See `feature/consolidate-auth-database` branch for implementation
|
||||
- Migration is ready but BLOCKED on Impress 2020 retirement
|
||||
|
||||
## Database Consolidation Blocker
|
||||
|
||||
**IMPORTANT**: A database consolidation migration exists on the `feature/consolidate-auth-database` branch that would merge the `openneo_id` database into `openneo_impress`. However, **this migration is blocked** because:
|
||||
|
||||
1. **Impress 2020 uses both databases directly** for authentication and user queries
|
||||
2. Consolidating now would break Impress 2020's login functionality
|
||||
3. The migration can only proceed after Impress 2020 is fully retired
|
||||
|
||||
**Options to unblock:**
|
||||
- **Preferred**: Complete Impress 2020 retirement first (Priority 1-3 migrations above)
|
||||
- **Alternative**: Coordinate simultaneous deployment of both services during maintenance window
|
||||
|
||||
See `docs/database-consolidation-deployment.md` (on feature branch) for full deployment plan.
|
||||
- **Single MySQL database**: Merge OpenNeo ID schema into main database
|
||||
|
||||
## Notes
|
||||
|
||||
|
|
@ -169,7 +150,6 @@ See `docs/database-consolidation-deployment.md` (on feature branch) for full dep
|
|||
- Many API calls have been successfully migrated from GraphQL to REST
|
||||
- The GraphQL dependency is primarily in the core outfit rendering logic
|
||||
- Support tools are the lowest priority since they're staff-only
|
||||
- Database consolidation is ready but awaiting Impress 2020 retirement
|
||||
|
||||
## See Also
|
||||
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
require "vips"
|
||||
|
||||
class OutfitImageRenderer
|
||||
CANVAS_SIZE = 600
|
||||
|
||||
def initialize(outfit)
|
||||
@outfit = outfit
|
||||
end
|
||||
|
||||
def render
|
||||
layers = @outfit.visible_layers
|
||||
|
||||
# Filter out layers without image URLs
|
||||
layers_with_images = layers.select(&:image_url?)
|
||||
|
||||
return nil if layers_with_images.empty?
|
||||
|
||||
# Fetch all layer images in parallel
|
||||
image_data_by_layer = fetch_layer_images(layers_with_images)
|
||||
|
||||
# Create transparent canvas in sRGB colorspace
|
||||
canvas = Vips::Image.black(CANVAS_SIZE, CANVAS_SIZE, bands: 4)
|
||||
canvas = canvas.new_from_image([0, 0, 0, 0])
|
||||
canvas = canvas.copy(interpretation: :srgb)
|
||||
|
||||
# Composite each layer onto the canvas
|
||||
layers_with_images.each do |layer|
|
||||
image_data = image_data_by_layer[layer]
|
||||
next unless image_data
|
||||
|
||||
begin
|
||||
layer_image = Vips::Image.new_from_buffer(image_data, "")
|
||||
|
||||
# Resize the layer to fit the canvas size
|
||||
# All layer images are square, but may not be CANVAS_SIZE x CANVAS_SIZE
|
||||
# We need to resize them to exactly CANVAS_SIZE x CANVAS_SIZE
|
||||
if layer_image.width != CANVAS_SIZE || layer_image.height != CANVAS_SIZE
|
||||
layer_image = layer_image.resize(
|
||||
CANVAS_SIZE.to_f / layer_image.width,
|
||||
vscale: CANVAS_SIZE.to_f / layer_image.height
|
||||
)
|
||||
end
|
||||
|
||||
# Composite this layer onto the canvas at (0, 0)
|
||||
# No offset needed since the layer is now exactly canvas-sized
|
||||
canvas = canvas.composite([layer_image], :over)
|
||||
rescue Vips::Error => e
|
||||
# Log and skip layers that fail to load/composite
|
||||
Rails.logger.warn "Failed to composite layer #{layer.id} (#{layer.image_url}): #{e.message}"
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
# Return PNG data
|
||||
canvas.write_to_buffer(".png")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_layer_images(layers)
|
||||
image_data_by_layer = {}
|
||||
|
||||
DTIRequests.load_many(max_at_once: 10) do |semaphore|
|
||||
layers.each do |layer|
|
||||
semaphore.async do
|
||||
begin
|
||||
response = DTIRequests.get(layer.image_url)
|
||||
if response.success?
|
||||
image_data_by_layer[layer] = response.read
|
||||
else
|
||||
Rails.logger.warn "Failed to fetch image for layer #{layer.id} (#{layer.image_url}): HTTP #{response.status}"
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn "Error fetching image for layer #{layer.id} (#{layer.image_url}): #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
image_data_by_layer
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -30,17 +30,24 @@ module RocketAMFExtensions
|
|||
raise RocketAMF::AMFError.new(first_message_data)
|
||||
end
|
||||
|
||||
# HACK: Older items in Neopets' database have Windows-1250 encoding,
|
||||
# while newer items use proper UTF-8. We detect which encoding was used
|
||||
# by checking if the string is valid UTF-8, and only re-encode if needed.
|
||||
# HACK: It seems to me that these messages come back with Windows-1250
|
||||
# (or similar) encoding on the strings? I'm basing this on the
|
||||
# Patchwork Staff item, whose description arrives as:
|
||||
#
|
||||
# Example of Windows-1250 item: Patchwork Staff (57311), whose
|
||||
# description contains byte 0x96 (en-dash in Windows-1250).
|
||||
# "That staff is cute, but dont use it as a walking stick \x96 I " +
|
||||
# "dont think it will hold you up!"
|
||||
#
|
||||
# Example of UTF-8 item: Carnival Party Décor (80042), whose name
|
||||
# contains proper UTF-8 bytes [195, 169] for the é character.
|
||||
# And the `\x96` is meant to represent an endash, which it doesn't in
|
||||
# UTF-8 or in most extended ASCII encodings, but *does* in Windows's
|
||||
# specific extended ASCII.
|
||||
#
|
||||
# Idk if this is something to do with the AMFPHP spec or how the AMFPHP
|
||||
# server code they use serializes strings (I couldn't find any
|
||||
# reference to it?), or just their internal database encoding being
|
||||
# passed along as-is, or what? But this seems to be the most correct
|
||||
# interpretation I know how to do, so, let's do it!
|
||||
result.messages[0].data.body.tap do |body|
|
||||
reencode_strings_if_needed! body, "Windows-1250", "UTF-8"
|
||||
reencode_strings! body, "Windows-1250", "UTF-8"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -85,17 +92,13 @@ module RocketAMFExtensions
|
|||
end
|
||||
end
|
||||
|
||||
def reencode_strings_if_needed!(target, from, to)
|
||||
def reencode_strings!(target, from, to)
|
||||
if target.is_a? String
|
||||
# Only re-encode if the string is not valid UTF-8
|
||||
# (indicating it's in the old Windows-1250 encoding)
|
||||
unless target.valid_encoding?
|
||||
target.force_encoding(from).encode!(to)
|
||||
end
|
||||
target.force_encoding(from).encode!(to)
|
||||
elsif target.is_a? Array
|
||||
target.each { |x| reencode_strings_if_needed!(x, from, to) }
|
||||
target.each { |x| reencode_strings!(x, from, to) }
|
||||
elsif target.is_a? Hash
|
||||
target.values.each { |x| reencode_strings_if_needed!(x, from, to) }
|
||||
target.values.each { |x| reencode_strings!(x, from, to) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,90 +9,4 @@ namespace :items do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
desc "Auto-model items on missing body types using NC Mall preview API"
|
||||
task :auto_model, [:limit] => :environment do |task, args|
|
||||
limit = (args[:limit] || 100).to_i
|
||||
dry_run = ENV["DRY_RUN"] == "1"
|
||||
auto_hint = ENV["AUTO_HINT"] != "0"
|
||||
|
||||
puts "Auto-modeling up to #{limit} items#{dry_run ? ' (DRY RUN)' : ''}..."
|
||||
puts "Auto-hint: #{auto_hint ? 'enabled' : 'disabled'}"
|
||||
puts
|
||||
|
||||
# Find items that need modeling, newest first
|
||||
items = Item.is_not_modeled.order(created_at: :desc).limit(limit)
|
||||
puts "Found #{items.count} items to process"
|
||||
puts
|
||||
|
||||
items.each_with_index do |item, index|
|
||||
puts "[#{index + 1}/#{items.count}] Item ##{item.id}: #{item.name}"
|
||||
|
||||
missing_body_ids = item.predicted_missing_body_ids
|
||||
if missing_body_ids.empty?
|
||||
puts " ⚠️ No missing body IDs (item may already be fully modeled)"
|
||||
puts
|
||||
next
|
||||
end
|
||||
|
||||
puts " Missing #{missing_body_ids.size} body IDs: #{missing_body_ids.join(', ')}"
|
||||
|
||||
# Track results for this item
|
||||
results = {modeled: 0, not_compatible: 0, not_found: 0}
|
||||
had_transient_error = false
|
||||
|
||||
missing_body_ids.each do |body_id|
|
||||
if dry_run
|
||||
puts " Body #{body_id}: [DRY RUN] would attempt modeling"
|
||||
next
|
||||
end
|
||||
|
||||
begin
|
||||
result = Pet::AutoModeling.model_item_on_body(item, body_id)
|
||||
results[result] += 1
|
||||
|
||||
case result
|
||||
when :modeled
|
||||
puts " Body #{body_id}: ✅ Modeled successfully"
|
||||
when :not_compatible
|
||||
puts " Body #{body_id}: ❌ Not compatible (heuristic over-predicted)"
|
||||
end
|
||||
rescue Pet::AutoModeling::NoPetTypeForBody => e
|
||||
puts " Body #{body_id}: ⚠️ #{e.message}"
|
||||
rescue Neopets::NCMall::ResponseNotOK => e
|
||||
if e.status >= 500
|
||||
puts " Body #{body_id}: ⚠️ Server error (#{e.status}), will retry later"
|
||||
had_transient_error = true
|
||||
else
|
||||
puts " Body #{body_id}: ❌ HTTP error (#{e.status})"
|
||||
Sentry.capture_exception(e)
|
||||
end
|
||||
rescue Neopets::NCMall::UnexpectedResponseFormat => e
|
||||
puts " Body #{body_id}: ❌ Unexpected response format: #{e.message}"
|
||||
Sentry.capture_exception(e)
|
||||
rescue Neopets::CustomPets::DownloadError => e
|
||||
puts " Body #{body_id}: ⚠️ AMF error: #{e.message}"
|
||||
had_transient_error = true
|
||||
end
|
||||
end
|
||||
|
||||
unless dry_run
|
||||
# Set hint if we've addressed all bodies without transient errors.
|
||||
# That way, if the item is not compatible with some bodies, we'll stop
|
||||
# trying to auto-model it.
|
||||
if auto_hint && !had_transient_error
|
||||
item.update!(modeling_status_hint: "done")
|
||||
puts " 📋 Set modeling_status_hint = 'done'"
|
||||
end
|
||||
end
|
||||
|
||||
puts " Summary: #{results[:modeled]} modeled, #{results[:not_compatible]} not compatible, #{results[:not_found]} not found"
|
||||
puts
|
||||
|
||||
# Be nice to Neopets API
|
||||
sleep 0.5 unless dry_run || index == items.count - 1
|
||||
end
|
||||
|
||||
puts "Done!"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,30 +1,7 @@
|
|||
namespace :pets do
|
||||
desc "Load a pet's viewer data (by name or by color/species/items)"
|
||||
task :load, [:first] => [:environment] do |task, args|
|
||||
# Collect all arguments (first + extras)
|
||||
all_args = [args[:first]] + args.extras
|
||||
|
||||
# If only one argument, treat it as a pet name
|
||||
if all_args.length == 1
|
||||
viewer_data = Neopets::CustomPets.fetch_viewer_data(all_args[0])
|
||||
else
|
||||
# Multiple arguments: color, species, and optional item IDs
|
||||
color_name = all_args[0]
|
||||
species_name = all_args[1]
|
||||
item_ids = all_args[2..]
|
||||
|
||||
# Look up the PetType to use for the preview
|
||||
pet_type = PetType.matching_name(color_name, species_name).first!
|
||||
|
||||
# Convert it to an image hash for direct lookup
|
||||
new_image_hash = Neopets::NCMall.fetch_pet_data_sci(pet_type.image_hash, item_ids)
|
||||
pet_name = '@' + new_image_hash
|
||||
$stderr.puts "Loading pet #{pet_name}"
|
||||
|
||||
# Load the image hash as if it were a pet
|
||||
viewer_data = Neopets::CustomPets.fetch_viewer_data(pet_name)
|
||||
end
|
||||
|
||||
desc "Load a pet's viewer data"
|
||||
task :load, [:name] => [:environment] do |task, args|
|
||||
viewer_data = Neopets::CustomPets.fetch_viewer_data(args[:name])
|
||||
puts JSON.pretty_generate(viewer_data)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -35,35 +35,12 @@
|
|||
font-weight: 400;
|
||||
letter-spacing: -0.0025em;
|
||||
line-height: 1.4;
|
||||
min-height: 100dvh;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
#error-description {
|
||||
fill: #d30001;
|
||||
}
|
||||
|
||||
#error-id {
|
||||
fill: #f0eff0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #101010;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
#error-description {
|
||||
fill: #FF6161;
|
||||
}
|
||||
|
||||
#error-id {
|
||||
fill: #2c2c2c;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
font-weight: 700;
|
||||
|
|
@ -106,11 +83,13 @@
|
|||
}
|
||||
|
||||
main article br {
|
||||
|
||||
display: none;
|
||||
|
||||
@media(min-width: 48em) {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -123,10 +102,10 @@
|
|||
|
||||
<main>
|
||||
<header>
|
||||
<svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm115.583 168.69891c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm140.456 133.2831c-40.823 0-64.884-35.146-64.884-85.7015 0-50.5554 24.061-85.700907 64.884-85.700907 40.822 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.062 85.7015-64.884 85.7015zm0-133.2831c-17.573 0-22.71 21.8984-22.71 47.5816 0 25.6835 5.137 47.5815 22.71 47.5815 17.302 0 22.709-21.898 22.709-47.5815 0-25.6832-5.407-47.5816-22.709-47.5816z" id="error-id"/><path d="m123.606 85.4445c3.212 1.0523 5.538 4.2089 5.538 8.0301 0 6.1472-4.209 9.5254-11.298 9.5254h-15.617v-34.0033h14.565c7.089 0 11.353 3.1566 11.353 9.2484 0 3.6551-2.049 6.3134-4.541 7.1994zm-12.904-2.9905h5.095c2.603 0 3.988-.9968 3.988-3.1013 0-2.1044-1.385-3.0459-3.988-3.0459h-5.095zm0 6.6456v6.5902h5.981c2.492 0 3.877-1.3291 3.877-3.2674 0-2.049-1.385-3.3228-3.877-3.3228zm43.786 13.9004h-8.362v-1.274c-.831.831-3.323 1.717-5.981 1.717-4.929 0-9.083-2.769-9.083-8.0301 0-4.818 4.154-7.9193 9.581-7.9193 2.049 0 4.486.6646 5.483 1.3845v-1.606c0-1.606-.942-2.9905-3.046-2.9905-1.606 0-2.548.7199-2.935 1.8275h-8.197c.72-4.8181 4.985-8.6393 11.409-8.6393 7.088 0 11.131 3.7659 11.131 10.2453zm-8.362-6.9779v-1.4399c-.554-1.0522-2.049-1.7167-3.655-1.7167-1.717 0-3.434.7199-3.434 2.3813 0 1.7168 1.717 2.4367 3.434 2.4367 1.606 0 3.101-.6645 3.655-1.6614zm27.996 6.9779v-1.994c-1.163 1.329-3.599 2.548-6.147 2.548-7.199 0-11.131-5.8151-11.131-13.0145s3.932-13.0143 11.131-13.0143c2.548 0 4.984 1.2184 6.147 2.5475v-13.0697h8.695v35.997zm0-9.1931v-6.5902c-.664-1.3291-2.159-2.326-3.821-2.326-2.99 0-4.763 2.4368-4.763 5.6488s1.773 5.5934 4.763 5.5934c1.717 0 3.157-.9415 3.821-2.326zm35.471-2.049h-3.101v11.2421h-8.806v-34.0033h15.285c7.31 0 12.35 4.1535 12.35 11.5744 0 5.1503-2.603 8.6947-6.757 10.2453l7.975 12.1836h-9.858zm-3.101-15.2849v8.1962h5.538c3.156 0 4.596-1.606 4.596-4.0981s-1.44-4.0981-4.596-4.0981zm36.957 17.8323h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.515-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.822-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm30.98 27.5234v-10.799c-1.163 1.329-3.6 2.548-6.147 2.548-7.2 0-11.132-5.9259-11.132-13.0145 0-7.144 3.932-13.0143 11.132-13.0143 2.547 0 4.984 1.2184 6.147 2.5475v-1.9937h8.695v33.726zm0-17.9981v-6.5902c-.665-1.3291-2.105-2.326-3.821-2.326-2.991 0-4.763 2.4368-4.763 5.6488s1.772 5.5934 4.763 5.5934c1.661 0 3.156-.9415 3.821-2.326zm36.789-15.7279v24.921h-8.695v-2.16c-1.329 1.551-3.821 2.714-6.646 2.714-5.482 0-8.75-3.5999-8.75-9.1379v-16.3371h8.64v14.288c0 2.1045.996 3.5997 3.212 3.5997 1.606 0 3.101-1.0522 3.544-2.769v-15.1187zm19.084 16.2263h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.515-13.0143 7.643 0 11.963 5.095 11.963 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.822-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm13.428 11.0206h8.474c.387 1.3845 1.606 2.1598 3.156 2.1598 1.44 0 2.548-.5538 2.548-1.7168 0-.9414-.72-1.2737-1.939-1.5506l-4.873-.9969c-4.154-.886-6.867-2.8797-6.867-7.2547 0-5.3165 4.762-8.4178 10.633-8.4178 6.812 0 10.522 3.1567 11.297 8.0855h-8.03c-.277-1.0522-1.052-1.9937-3.046-1.9937-1.273 0-2.326.5538-2.326 1.6614 0 .7753.554 1.163 1.717 1.3845l4.929 1.163c4.541 1.0522 6.978 3.4335 6.978 7.4763 0 5.3168-4.818 8.2518-10.91 8.2518-6.369 0-10.965-2.88-11.741-8.2518zm27.538-.8861v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.205v6.7564h-5.205v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.993-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.871 0-9.193-2.769-9.193-9.0819z" id="error-description"/></svg>
|
||||
<svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm115.583 168.69891c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm140.456 133.2831c-40.823 0-64.884-35.146-64.884-85.7015 0-50.5554 24.061-85.700907 64.884-85.700907 40.822 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.062 85.7015-64.884 85.7015zm0-133.2831c-17.573 0-22.71 21.8984-22.71 47.5816 0 25.6835 5.137 47.5815 22.71 47.5815 17.302 0 22.709-21.898 22.709-47.5815 0-25.6832-5.407-47.5816-22.709-47.5816z" fill="#f0eff0"/><path d="m123.606 85.4445c3.212 1.0523 5.538 4.2089 5.538 8.0301 0 6.1472-4.209 9.5254-11.298 9.5254h-15.617v-34.0033h14.565c7.089 0 11.353 3.1566 11.353 9.2484 0 3.6551-2.049 6.3134-4.541 7.1994zm-12.904-2.9905h5.095c2.603 0 3.988-.9968 3.988-3.1013 0-2.1044-1.385-3.0459-3.988-3.0459h-5.095zm0 6.6456v6.5902h5.981c2.492 0 3.877-1.3291 3.877-3.2674 0-2.049-1.385-3.3228-3.877-3.3228zm43.786 13.9004h-8.362v-1.274c-.831.831-3.323 1.717-5.981 1.717-4.929 0-9.083-2.769-9.083-8.0301 0-4.818 4.154-7.9193 9.581-7.9193 2.049 0 4.486.6646 5.483 1.3845v-1.606c0-1.606-.942-2.9905-3.046-2.9905-1.606 0-2.548.7199-2.935 1.8275h-8.197c.72-4.8181 4.985-8.6393 11.409-8.6393 7.088 0 11.131 3.7659 11.131 10.2453zm-8.362-6.9779v-1.4399c-.554-1.0522-2.049-1.7167-3.655-1.7167-1.717 0-3.434.7199-3.434 2.3813 0 1.7168 1.717 2.4367 3.434 2.4367 1.606 0 3.101-.6645 3.655-1.6614zm27.996 6.9779v-1.994c-1.163 1.329-3.599 2.548-6.147 2.548-7.199 0-11.131-5.8151-11.131-13.0145s3.932-13.0143 11.131-13.0143c2.548 0 4.984 1.2184 6.147 2.5475v-13.0697h8.695v35.997zm0-9.1931v-6.5902c-.664-1.3291-2.159-2.326-3.821-2.326-2.99 0-4.763 2.4368-4.763 5.6488s1.773 5.5934 4.763 5.5934c1.717 0 3.157-.9415 3.821-2.326zm35.471-2.049h-3.101v11.2421h-8.806v-34.0033h15.285c7.31 0 12.35 4.1535 12.35 11.5744 0 5.1503-2.603 8.6947-6.757 10.2453l7.975 12.1836h-9.858zm-3.101-15.2849v8.1962h5.538c3.156 0 4.596-1.606 4.596-4.0981s-1.44-4.0981-4.596-4.0981zm36.957 17.8323h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.515-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.822-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm30.98 27.5234v-10.799c-1.163 1.329-3.6 2.548-6.147 2.548-7.2 0-11.132-5.9259-11.132-13.0145 0-7.144 3.932-13.0143 11.132-13.0143 2.547 0 4.984 1.2184 6.147 2.5475v-1.9937h8.695v33.726zm0-17.9981v-6.5902c-.665-1.3291-2.105-2.326-3.821-2.326-2.991 0-4.763 2.4368-4.763 5.6488s1.772 5.5934 4.763 5.5934c1.661 0 3.156-.9415 3.821-2.326zm36.789-15.7279v24.921h-8.695v-2.16c-1.329 1.551-3.821 2.714-6.646 2.714-5.482 0-8.75-3.5999-8.75-9.1379v-16.3371h8.64v14.288c0 2.1045.996 3.5997 3.212 3.5997 1.606 0 3.101-1.0522 3.544-2.769v-15.1187zm19.084 16.2263h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.515-13.0143 7.643 0 11.963 5.095 11.963 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.822-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm13.428 11.0206h8.474c.387 1.3845 1.606 2.1598 3.156 2.1598 1.44 0 2.548-.5538 2.548-1.7168 0-.9414-.72-1.2737-1.939-1.5506l-4.873-.9969c-4.154-.886-6.867-2.8797-6.867-7.2547 0-5.3165 4.762-8.4178 10.633-8.4178 6.812 0 10.522 3.1567 11.297 8.0855h-8.03c-.277-1.0522-1.052-1.9937-3.046-1.9937-1.273 0-2.326.5538-2.326 1.6614 0 .7753.554 1.163 1.717 1.3845l4.929 1.163c4.541 1.0522 6.978 3.4335 6.978 7.4763 0 5.3168-4.818 8.2518-10.91 8.2518-6.369 0-10.965-2.88-11.741-8.2518zm27.538-.8861v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.205v6.7564h-5.205v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.993-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.871 0-9.193-2.769-9.193-9.0819z" fill="#d30001"/></svg>
|
||||
</header>
|
||||
<article>
|
||||
<p><strong>The server cannot process the request due to a client error.</strong> Please check the request and try again. If you're the application owner check the logs for more information.</p>
|
||||
<p><strong>The server cannot process the request due to a client error.</strong> Please check the request and try again. If you’re the application owner check the logs for more information.</p>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -35,35 +35,12 @@
|
|||
font-weight: 400;
|
||||
letter-spacing: -0.0025em;
|
||||
line-height: 1.4;
|
||||
min-height: 100dvh;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
#error-description {
|
||||
fill: #d30001;
|
||||
}
|
||||
|
||||
#error-id {
|
||||
fill: #f0eff0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #101010;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
#error-description {
|
||||
fill: #FF6161;
|
||||
}
|
||||
|
||||
#error-id {
|
||||
fill: #2c2c2c;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
font-weight: 700;
|
||||
|
|
@ -106,11 +83,13 @@
|
|||
}
|
||||
|
||||
main article br {
|
||||
|
||||
display: none;
|
||||
|
||||
@media(min-width: 48em) {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -123,7 +102,7 @@
|
|||
|
||||
<main>
|
||||
<header>
|
||||
<svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm115.583 168.69891c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm202.906 9.7326h-41.093c-2.433-7.2994-7.84-12.4361-17.302-12.4361-16.221 0-25.413 17.5728-25.954 34.8752v1.3517c5.137-7.0291 16.221-12.4361 30.82-12.4361 33.524 0 54.881 24.0612 54.881 53.7998 0 33.253-23.791 58.396-61.64 58.396-21.628 0-39.741-10.003-50.825-27.576-9.733-14.599-13.788-32.442-13.788-54.3406 0-51.9072 24.331-89.485807 66.236-89.485807 32.712 0 53.258 18.654107 58.665 47.851907zm-82.727 66.2355c0 13.247 9.463 22.439 22.71 22.439 12.977 0 22.439-9.192 22.439-22.439 0-13.517-9.462-22.7091-22.439-22.7091-13.247 0-22.71 9.1921-22.71 22.7091z" id="error-id"/><path d="m100.761 68.9967v34.0033h-7.1991l-14.2326-19.8814v19.8814h-8.5839v-34.0033h8.307l13.125 18.7184v-18.7184zm28.454 21.5428c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.529 0c0-3.4336-1.495-5.8703-4.208-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.208-2.3813 4.208-5.8149zm13.185 3.8766v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.205v6.7564h-5.205v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm39.02-25.4194h9.083l12.958 34.0033h-9.027l-2.436-6.5902h-12.35l-2.381 6.5902h-8.806zm4.431 10.5222-3.489 9.5807h6.978zm17.44 11.0206c0-7.6978 5.095-13.0143 12.572-13.0143 6.701 0 10.854 3.9874 11.574 9.8023h-8.418c-.221-1.4953-1.384-2.6029-3.156-2.6029-2.437 0-3.988 2.2706-3.988 5.8149s1.551 5.7595 3.988 5.7595c1.772 0 2.935-1.0522 3.156-2.5475h8.418c-.72 5.7596-4.873 9.8025-11.574 9.8025-7.477 0-12.572-5.3167-12.572-13.0145zm25.676 0c0-7.6978 5.095-13.0143 12.572-13.0143 6.701 0 10.854 3.9874 11.574 9.8023h-8.418c-.221-1.4953-1.384-2.6029-3.156-2.6029-2.437 0-3.988 2.2706-3.988 5.8149s1.551 5.7595 3.988 5.7595c1.772 0 2.935-1.0522 3.156-2.5475h8.418c-.72 5.7596-4.873 9.8025-11.574 9.8025-7.477 0-12.572-5.3167-12.572-13.0145zm42.013 3.7658h8.031c-.887 5.7597-5.206 9.2487-11.686 9.2487-7.642 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.317-13.0143 12.516-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.319 4.5965 1.773 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm23.4 16.7244v10.799h-8.694v-33.726h8.694v1.9937c1.163-1.3291 3.6-2.5475 6.148-2.5475 7.199 0 11.131 5.8703 11.131 13.0143 0 7.0886-3.932 13.0145-11.131 13.0145-2.548 0-4.985-1.219-6.148-2.548zm0-13.7893v6.5902c.665 1.3845 2.16 2.326 3.822 2.326 2.99 0 4.762-2.3814 4.762-5.5934s-1.772-5.6488-4.762-5.6488c-1.717 0-3.157.9969-3.822 2.326zm21.892 7.1994v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.206v6.7564h-5.206v8.307c0 1.9383.941 2.769 2.658 2.769.942 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm39.458 8.5839h-8.363v-1.274c-.83.831-3.322 1.717-5.981 1.717-4.928 0-9.082-2.769-9.082-8.0301 0-4.818 4.154-7.9193 9.581-7.9193 2.049 0 4.486.6646 5.482 1.3845v-1.606c0-1.606-.941-2.9905-3.045-2.9905-1.606 0-2.548.7199-2.936 1.8275h-8.196c.72-4.8181 4.984-8.6393 11.408-8.6393 7.089 0 11.132 3.7659 11.132 10.2453zm-8.363-6.9779v-1.4399c-.553-1.0522-2.049-1.7167-3.655-1.7167-1.716 0-3.433.7199-3.433 2.3813 0 1.7168 1.717 2.4367 3.433 2.4367 1.606 0 3.102-.6645 3.655-1.6614zm20.742 4.9839v1.994h-8.694v-35.997h8.694v13.0697c1.163-1.3291 3.6-2.5475 6.148-2.5475 7.199 0 11.131 5.8149 11.131 13.0143s-3.932 13.0145-11.131 13.0145c-2.548 0-4.985-1.219-6.148-2.548zm0-13.7893v6.5902c.665 1.3845 2.105 2.326 3.822 2.326 2.99 0 4.762-2.3814 4.762-5.5934s-1.772-5.6488-4.762-5.6488c-1.662 0-3.157.9969-3.822 2.326zm28.759-20.2137v35.997h-8.695v-35.997zm19.172 27.3023h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.516-13.0143 7.642 0 11.962 5.095 11.962 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.434 1.2737-3.988 3.5997h7.532c-.111-2.0491-1.384-3.5997-3.544-3.5997z" id="error-description"/></svg>
|
||||
<svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm115.583 168.69891c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm202.906 9.7326h-41.093c-2.433-7.2994-7.84-12.4361-17.302-12.4361-16.221 0-25.413 17.5728-25.954 34.8752v1.3517c5.137-7.0291 16.221-12.4361 30.82-12.4361 33.524 0 54.881 24.0612 54.881 53.7998 0 33.253-23.791 58.396-61.64 58.396-21.628 0-39.741-10.003-50.825-27.576-9.733-14.599-13.788-32.442-13.788-54.3406 0-51.9072 24.331-89.485807 66.236-89.485807 32.712 0 53.258 18.654107 58.665 47.851907zm-82.727 66.2355c0 13.247 9.463 22.439 22.71 22.439 12.977 0 22.439-9.192 22.439-22.439 0-13.517-9.462-22.7091-22.439-22.7091-13.247 0-22.71 9.1921-22.71 22.7091z" fill="#f0eff0"/><path d="m100.761 68.9967v34.0033h-7.1991l-14.2326-19.8814v19.8814h-8.5839v-34.0033h8.307l13.125 18.7184v-18.7184zm28.454 21.5428c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.529 0c0-3.4336-1.495-5.8703-4.208-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.208-2.3813 4.208-5.8149zm13.185 3.8766v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.205v6.7564h-5.205v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm39.02-25.4194h9.083l12.958 34.0033h-9.027l-2.436-6.5902h-12.35l-2.381 6.5902h-8.806zm4.431 10.5222-3.489 9.5807h6.978zm17.44 11.0206c0-7.6978 5.095-13.0143 12.572-13.0143 6.701 0 10.854 3.9874 11.574 9.8023h-8.418c-.221-1.4953-1.384-2.6029-3.156-2.6029-2.437 0-3.988 2.2706-3.988 5.8149s1.551 5.7595 3.988 5.7595c1.772 0 2.935-1.0522 3.156-2.5475h8.418c-.72 5.7596-4.873 9.8025-11.574 9.8025-7.477 0-12.572-5.3167-12.572-13.0145zm25.676 0c0-7.6978 5.095-13.0143 12.572-13.0143 6.701 0 10.854 3.9874 11.574 9.8023h-8.418c-.221-1.4953-1.384-2.6029-3.156-2.6029-2.437 0-3.988 2.2706-3.988 5.8149s1.551 5.7595 3.988 5.7595c1.772 0 2.935-1.0522 3.156-2.5475h8.418c-.72 5.7596-4.873 9.8025-11.574 9.8025-7.477 0-12.572-5.3167-12.572-13.0145zm42.013 3.7658h8.031c-.887 5.7597-5.206 9.2487-11.686 9.2487-7.642 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.317-13.0143 12.516-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.319 4.5965 1.773 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm23.4 16.7244v10.799h-8.694v-33.726h8.694v1.9937c1.163-1.3291 3.6-2.5475 6.148-2.5475 7.199 0 11.131 5.8703 11.131 13.0143 0 7.0886-3.932 13.0145-11.131 13.0145-2.548 0-4.985-1.219-6.148-2.548zm0-13.7893v6.5902c.665 1.3845 2.16 2.326 3.822 2.326 2.99 0 4.762-2.3814 4.762-5.5934s-1.772-5.6488-4.762-5.6488c-1.717 0-3.157.9969-3.822 2.326zm21.892 7.1994v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.206v6.7564h-5.206v8.307c0 1.9383.941 2.769 2.658 2.769.942 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm39.458 8.5839h-8.363v-1.274c-.83.831-3.322 1.717-5.981 1.717-4.928 0-9.082-2.769-9.082-8.0301 0-4.818 4.154-7.9193 9.581-7.9193 2.049 0 4.486.6646 5.482 1.3845v-1.606c0-1.606-.941-2.9905-3.045-2.9905-1.606 0-2.548.7199-2.936 1.8275h-8.196c.72-4.8181 4.984-8.6393 11.408-8.6393 7.089 0 11.132 3.7659 11.132 10.2453zm-8.363-6.9779v-1.4399c-.553-1.0522-2.049-1.7167-3.655-1.7167-1.716 0-3.433.7199-3.433 2.3813 0 1.7168 1.717 2.4367 3.433 2.4367 1.606 0 3.102-.6645 3.655-1.6614zm20.742 4.9839v1.994h-8.694v-35.997h8.694v13.0697c1.163-1.3291 3.6-2.5475 6.148-2.5475 7.199 0 11.131 5.8149 11.131 13.0143s-3.932 13.0145-11.131 13.0145c-2.548 0-4.985-1.219-6.148-2.548zm0-13.7893v6.5902c.665 1.3845 2.105 2.326 3.822 2.326 2.99 0 4.762-2.3814 4.762-5.5934s-1.772-5.6488-4.762-5.6488c-1.662 0-3.157.9969-3.822 2.326zm28.759-20.2137v35.997h-8.695v-35.997zm19.172 27.3023h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.516-13.0143 7.642 0 11.962 5.095 11.962 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.434 1.2737-3.988 3.5997h7.532c-.111-2.0491-1.384-3.5997-3.544-3.5997z" fill="#d30001"/></svg>
|
||||
</header>
|
||||
<article>
|
||||
<p><strong>Your browser is not supported.</strong><br> Please upgrade your browser to continue.</p>
|
||||
|
|
|
|||
157
public/422.html
157
public/422.html
File diff suppressed because one or more lines are too long
157
public/500.html
157
public/500.html
File diff suppressed because one or more lines are too long
|
|
@ -1,119 +0,0 @@
|
|||
require_relative '../rails_helper'
|
||||
|
||||
RSpec.describe UsersController, type: :controller do
|
||||
include Devise::Test::ControllerHelpers
|
||||
|
||||
describe 'GET #top_contributors' do
|
||||
let!(:user1) { create_user('Alice', 100) }
|
||||
let!(:user2) { create_user('Bob', 50) }
|
||||
let!(:user3) { create_user('Charlie', 0) }
|
||||
|
||||
context 'without timeframe parameter' do
|
||||
it 'defaults to all_time timeframe' do
|
||||
get :top_contributors
|
||||
expect(assigns(:timeframe)).to eq('all_time')
|
||||
end
|
||||
|
||||
it 'returns users ordered by points' do
|
||||
get :top_contributors
|
||||
users = assigns(:users)
|
||||
expect(users.to_a.map(&:id)).to eq([user1.id, user2.id])
|
||||
end
|
||||
|
||||
it 'paginates results' do
|
||||
get :top_contributors, params: { page: 1 }
|
||||
users = assigns(:users)
|
||||
expect(users).to respond_to(:total_pages)
|
||||
expect(users).to respond_to(:current_page)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with valid timeframe parameter' do
|
||||
it 'accepts all_time' do
|
||||
get :top_contributors, params: { timeframe: 'all_time' }
|
||||
expect(assigns(:timeframe)).to eq('all_time')
|
||||
end
|
||||
|
||||
it 'accepts this_year' do
|
||||
get :top_contributors, params: { timeframe: 'this_year' }
|
||||
expect(assigns(:timeframe)).to eq('this_year')
|
||||
end
|
||||
|
||||
it 'accepts this_month' do
|
||||
get :top_contributors, params: { timeframe: 'this_month' }
|
||||
expect(assigns(:timeframe)).to eq('this_month')
|
||||
end
|
||||
|
||||
it 'accepts this_week' do
|
||||
get :top_contributors, params: { timeframe: 'this_week' }
|
||||
expect(assigns(:timeframe)).to eq('this_week')
|
||||
end
|
||||
|
||||
it 'calls User.top_contributors_for with the timeframe' do
|
||||
expect(User).to receive(:top_contributors_for).with(:this_week).and_call_original
|
||||
get :top_contributors, params: { timeframe: 'this_week' }
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid timeframe parameter' do
|
||||
it 'defaults to all_time' do
|
||||
get :top_contributors, params: { timeframe: 'invalid' }
|
||||
expect(assigns(:timeframe)).to eq('all_time')
|
||||
end
|
||||
|
||||
it 'does not raise an error' do
|
||||
expect {
|
||||
get :top_contributors, params: { timeframe: 'invalid' }
|
||||
}.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'with pagination' do
|
||||
before do
|
||||
# Create 25 users to test pagination (per_page is 20)
|
||||
25.times do |i|
|
||||
create_user("User#{i}", 100 - i)
|
||||
end
|
||||
end
|
||||
|
||||
it 'paginates with 20 users per page' do
|
||||
get :top_contributors
|
||||
expect(assigns(:users).size).to eq(20)
|
||||
end
|
||||
|
||||
it 'supports page parameter' do
|
||||
get :top_contributors, params: { page: 2 }
|
||||
expect(assigns(:users).current_page).to eq(2)
|
||||
end
|
||||
|
||||
it 'works with timeframe and pagination together' do
|
||||
get :top_contributors, params: { timeframe: 'all_time', page: 2 }
|
||||
expect(assigns(:timeframe)).to eq('all_time')
|
||||
expect(assigns(:users).current_page).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'renders the correct template' do
|
||||
it 'renders the top_contributors template' do
|
||||
get :top_contributors
|
||||
expect(response).to render_template('top_contributors')
|
||||
end
|
||||
|
||||
it 'returns HTTP success' do
|
||||
get :top_contributors
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Helper methods
|
||||
def create_user(name, points = 0)
|
||||
auth_user = AuthUser.create!(
|
||||
name: name,
|
||||
email: "#{name.downcase}@example.com",
|
||||
password: 'password123',
|
||||
password_confirmation: 'password123'
|
||||
)
|
||||
User.create!(name: name, remote_id: auth_user.id, auth_server_id: 1, points: points)
|
||||
end
|
||||
end
|
||||
BIN
spec/fixtures/outfit_images/Blue Acara With Cape.png
vendored
BIN
spec/fixtures/outfit_images/Blue Acara With Cape.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
BIN
spec/fixtures/outfit_images/Blue Acara With Hat.png
vendored
BIN
spec/fixtures/outfit_images/Blue Acara With Hat.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB |
BIN
spec/fixtures/outfit_images/Blue Acara.png
vendored
BIN
spec/fixtures/outfit_images/Blue Acara.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 79 KiB |
BIN
spec/fixtures/outfit_images/Cape.png
vendored
BIN
spec/fixtures/outfit_images/Cape.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB |
BIN
spec/fixtures/outfit_images/Hat.png
vendored
BIN
spec/fixtures/outfit_images/Hat.png
vendored
Binary file not shown.
|
Before 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
|
||||
markings1:
|
||||
id: 6
|
||||
depth: 8
|
||||
markings:
|
||||
id: 31
|
||||
depth: 35
|
||||
type_id: 2
|
||||
label: Markings
|
||||
plain_label: markings
|
||||
|
|
@ -88,12 +88,6 @@ body:
|
|||
type_id: 1
|
||||
label: Body
|
||||
plain_label: body
|
||||
markings2:
|
||||
id: 16
|
||||
depth: 19
|
||||
type_id: 2
|
||||
label: Markings
|
||||
plain_label: markings
|
||||
bodydisease:
|
||||
id: 17
|
||||
depth: 20
|
||||
|
|
@ -178,12 +172,6 @@ head:
|
|||
type_id: 1
|
||||
label: Head
|
||||
plain_label: head
|
||||
markings3:
|
||||
id: 31
|
||||
depth: 35
|
||||
type_id: 2
|
||||
label: Markings
|
||||
plain_label: markings
|
||||
headdisease:
|
||||
id: 32
|
||||
depth: 36
|
||||
|
|
@ -208,9 +196,9 @@ glasses:
|
|||
type_id: 2
|
||||
label: Glasses
|
||||
plain_label: glasses
|
||||
earrings1:
|
||||
id: 36
|
||||
depth: 39
|
||||
earrings:
|
||||
id: 41
|
||||
depth: 45
|
||||
type_id: 2
|
||||
label: Earrings
|
||||
plain_label: earrings
|
||||
|
|
@ -232,21 +220,15 @@ headdrippings:
|
|||
type_id: 1
|
||||
label: Head Drippings
|
||||
plain_label: headdrippings
|
||||
hat1:
|
||||
id: 40
|
||||
depth: 44
|
||||
hat:
|
||||
id: 50
|
||||
depth: 16
|
||||
type_id: 2
|
||||
label: Hat
|
||||
plain_label: hat
|
||||
earrings2:
|
||||
id: 41
|
||||
depth: 45
|
||||
type_id: 2
|
||||
label: Earrings
|
||||
plain_label: earrings
|
||||
righthanditem1:
|
||||
id: 42
|
||||
depth: 46
|
||||
righthanditem:
|
||||
id: 49
|
||||
depth: 5
|
||||
type_id: 2
|
||||
label: Right-hand Item
|
||||
plain_label: righthanditem
|
||||
|
|
@ -286,18 +268,6 @@ backgrounditem:
|
|||
type_id: 3
|
||||
label: Background Item
|
||||
plain_label: backgrounditem
|
||||
righthanditem2:
|
||||
id: 49
|
||||
depth: 5
|
||||
type_id: 2
|
||||
label: Right-hand Item
|
||||
plain_label: righthanditem
|
||||
hat2:
|
||||
id: 50
|
||||
depth: 16
|
||||
type_id: 2
|
||||
label: Hat
|
||||
plain_label: hat
|
||||
belt:
|
||||
id: 51
|
||||
depth: 27
|
||||
|
|
|
|||
|
|
@ -1,240 +0,0 @@
|
|||
require 'webmock/rspec'
|
||||
require_relative '../rails_helper'
|
||||
|
||||
RSpec.describe OutfitImageRenderer do
|
||||
fixtures :zones, :colors, :species
|
||||
|
||||
# Helper to load a fixture image
|
||||
def load_fixture_image(filename)
|
||||
path = Rails.root.join('spec', 'fixtures', 'outfit_images', filename)
|
||||
File.read(path)
|
||||
end
|
||||
|
||||
# Helper to create a pet state with specific swf_assets
|
||||
def build_pet_state(pet_type, pose: "HAPPY_FEM", swf_assets: [])
|
||||
pet_state = PetState.create!(
|
||||
pet_type: pet_type,
|
||||
pose: pose,
|
||||
swf_asset_ids: swf_assets.empty? ? [0] : swf_assets.map(&:id)
|
||||
)
|
||||
pet_state.swf_assets = swf_assets
|
||||
pet_state
|
||||
end
|
||||
|
||||
# Helper to create a SwfAsset for biology (pet layers)
|
||||
def build_biology_asset(zone, body_id:)
|
||||
@remote_id = (@remote_id || 0) + 1
|
||||
SwfAsset.create!(
|
||||
type: "biology",
|
||||
remote_id: @remote_id,
|
||||
url: "https://images.neopets.example/biology_#{@remote_id}.swf",
|
||||
zone: zone,
|
||||
body_id: body_id,
|
||||
zones_restrict: "",
|
||||
has_image: true
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to create a SwfAsset for items (object layers)
|
||||
def build_item_asset(zone, body_id:)
|
||||
@remote_id = (@remote_id || 0) + 1
|
||||
SwfAsset.create!(
|
||||
type: "object",
|
||||
remote_id: @remote_id,
|
||||
url: "https://images.neopets.example/object_#{@remote_id}.swf",
|
||||
zone: zone,
|
||||
body_id: body_id,
|
||||
zones_restrict: "",
|
||||
has_image: true
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to create an item with specific swf_assets
|
||||
def build_item(name, swf_assets: [])
|
||||
item = Item.create!(
|
||||
name: name,
|
||||
description: "Test item",
|
||||
thumbnail_url: "https://images.neopets.example/thumbnail.png",
|
||||
rarity: "Common",
|
||||
price: 100,
|
||||
zones_restrict: "",
|
||||
species_support_ids: ""
|
||||
)
|
||||
swf_assets.each do |asset|
|
||||
ParentSwfAssetRelationship.create!(
|
||||
parent: item,
|
||||
swf_asset: asset
|
||||
)
|
||||
end
|
||||
item
|
||||
end
|
||||
|
||||
before do
|
||||
PetType.destroy_all
|
||||
@pet_type = PetType.create!(
|
||||
species: species(:acara),
|
||||
color: colors(:blue),
|
||||
body_id: 1,
|
||||
created_at: Time.new(2005)
|
||||
)
|
||||
end
|
||||
|
||||
describe "#render" do
|
||||
context "with a simple outfit" do
|
||||
it "composites biology and item layers into a single PNG" do
|
||||
# Load fixture images
|
||||
acara_png = load_fixture_image('Blue Acara.png')
|
||||
hat_png = load_fixture_image('Hat.png')
|
||||
expected_composite_png = load_fixture_image('Blue Acara With Hat.png')
|
||||
|
||||
# Create biology and item assets
|
||||
biology_asset = build_biology_asset(zones(:head), body_id: 1)
|
||||
item_asset = build_item_asset(zones(:hat1), body_id: 1)
|
||||
|
||||
# Stub HTTP requests for the actual image URLs that will be generated
|
||||
stub_request(:get, biology_asset.image_url).
|
||||
to_return(body: acara_png, status: 200)
|
||||
stub_request(:get, item_asset.image_url).
|
||||
to_return(body: hat_png, status: 200)
|
||||
|
||||
# Build outfit
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
|
||||
item = build_item("Test Hat", swf_assets: [item_asset])
|
||||
outfit = Outfit.new(
|
||||
pet_state: pet_state,
|
||||
worn_items: [item]
|
||||
)
|
||||
|
||||
# Render
|
||||
renderer = OutfitImageRenderer.new(outfit)
|
||||
result = renderer.render
|
||||
|
||||
# Verify we got PNG data back
|
||||
expect(result).not_to be_nil
|
||||
expect(result).to be_a(String)
|
||||
expect(result[0..7]).to eq("\x89PNG\r\n\x1A\n".b) # PNG magic bytes
|
||||
|
||||
# Verify the result is a valid 600x600 PNG
|
||||
result_image = Vips::Image.new_from_buffer(result, "")
|
||||
expect(result_image.width).to eq(600)
|
||||
expect(result_image.height).to eq(600)
|
||||
|
||||
# Verify the composite matches the expected image pixel-perfectly
|
||||
expected_image = Vips::Image.new_from_buffer(expected_composite_png, "")
|
||||
|
||||
# Calculate the absolute difference between images
|
||||
diff = (result_image - expected_image).abs
|
||||
max_diff = diff.max
|
||||
|
||||
# Allow a small tolerance for minor encoding/compositing differences
|
||||
# The expected image was generated with a different method, so we expect
|
||||
# very close but not necessarily pixel-perfect matches
|
||||
tolerance = 2
|
||||
if max_diff > tolerance
|
||||
debug_path = Rails.root.join('tmp', 'test_render_result.png')
|
||||
result_image.write_to_file(debug_path.to_s)
|
||||
fail "Images should match within tolerance of #{tolerance}, but found max difference of #{max_diff}. Actual output saved to #{debug_path}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when a layer image fails to load" do
|
||||
it "skips the failed layer and continues" do
|
||||
hat_png = load_fixture_image('Hat.png')
|
||||
|
||||
biology_asset = build_biology_asset(zones(:head), body_id: 1)
|
||||
item_asset = build_item_asset(zones(:hat1), body_id: 1)
|
||||
|
||||
# Stub one successful request and one failure
|
||||
stub_request(:get, biology_asset.image_url).
|
||||
to_return(status: 404)
|
||||
stub_request(:get, item_asset.image_url).
|
||||
to_return(body: hat_png, status: 200)
|
||||
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
|
||||
item = build_item("Test Hat", swf_assets: [item_asset])
|
||||
outfit = Outfit.new(
|
||||
pet_state: pet_state,
|
||||
worn_items: [item]
|
||||
)
|
||||
|
||||
renderer = OutfitImageRenderer.new(outfit)
|
||||
result = renderer.render
|
||||
|
||||
# Should still render successfully with just the one layer
|
||||
expect(result).not_to be_nil
|
||||
expect(result[0..7]).to eq("\x89PNG\r\n\x1A\n".b)
|
||||
end
|
||||
end
|
||||
|
||||
context "when no layers have images" do
|
||||
it "returns nil" do
|
||||
# Create an asset but stub image_url to return nil
|
||||
biology_asset = build_biology_asset(zones(:head), body_id: 1)
|
||||
allow_any_instance_of(SwfAsset).to receive(:image_url?).and_return(false)
|
||||
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
|
||||
renderer = OutfitImageRenderer.new(outfit)
|
||||
result = renderer.render
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
it "resizes all layers to 600x600 before compositing" do
|
||||
# Load a 1200x1200 item layer (real-world case from Neopets)
|
||||
item_1200_png = load_fixture_image('Cape.png')
|
||||
acara_600_png = load_fixture_image('Blue Acara.png')
|
||||
expected_composite_png = load_fixture_image('Blue Acara With Cape.png')
|
||||
|
||||
# Create assets
|
||||
biology_asset = build_biology_asset(zones(:head), body_id: 1)
|
||||
item_asset = build_item_asset(zones(:hat1), body_id: 1)
|
||||
|
||||
# Stub HTTP requests
|
||||
stub_request(:get, biology_asset.image_url).
|
||||
to_return(body: acara_600_png, status: 200)
|
||||
stub_request(:get, item_asset.image_url).
|
||||
to_return(body: item_1200_png, status: 200)
|
||||
|
||||
# Build outfit
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
|
||||
item = build_item("Test Item", swf_assets: [item_asset])
|
||||
outfit = Outfit.new(
|
||||
pet_state: pet_state,
|
||||
worn_items: [item]
|
||||
)
|
||||
|
||||
# Render
|
||||
renderer = OutfitImageRenderer.new(outfit)
|
||||
result = renderer.render
|
||||
|
||||
# Verify we got valid PNG data
|
||||
expect(result).not_to be_nil
|
||||
expect(result).to be_a(String)
|
||||
expect(result[0..7]).to eq("\x89PNG\r\n\x1A\n".b)
|
||||
|
||||
# Verify the result is exactly 600x600
|
||||
result_image = Vips::Image.new_from_buffer(result, "")
|
||||
expect(result_image.width).to eq(600)
|
||||
expect(result_image.height).to eq(600)
|
||||
|
||||
# Verify the composite matches the expected image pixel-perfectly
|
||||
expected_image = Vips::Image.new_from_buffer(expected_composite_png, "")
|
||||
|
||||
# Calculate the absolute difference between images
|
||||
diff = (result_image - expected_image).abs
|
||||
max_diff = diff.max
|
||||
|
||||
# Allow a small tolerance for minor encoding/compositing differences
|
||||
tolerance = 2
|
||||
if max_diff > tolerance
|
||||
debug_path = Rails.root.join('tmp', 'test_render_1200_result.png')
|
||||
result_image.write_to_file(debug_path.to_s)
|
||||
fail "Images should match within tolerance of #{tolerance}, but found max difference of #{max_diff}. Actual output saved to #{debug_path}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,546 +0,0 @@
|
|||
require_relative '../rails_helper'
|
||||
|
||||
RSpec.describe Outfit do
|
||||
fixtures :zones, :colors, :species
|
||||
|
||||
# Helper to create a pet state with specific swf_assets
|
||||
def build_pet_state(pet_type, pose: "HAPPY_FEM", swf_assets: [])
|
||||
pet_state = PetState.create!(
|
||||
pet_type: pet_type,
|
||||
pose: pose,
|
||||
swf_asset_ids: swf_assets.empty? ? [0] : swf_assets.map(&:id)
|
||||
)
|
||||
pet_state.swf_assets = swf_assets
|
||||
pet_state
|
||||
end
|
||||
|
||||
# Helper to create a SwfAsset for biology (pet layers)
|
||||
def build_biology_asset(zone, body_id:, zones_restrict: "")
|
||||
@remote_id = (@remote_id || 0) + 1
|
||||
SwfAsset.create!(
|
||||
type: "biology",
|
||||
remote_id: @remote_id,
|
||||
url: "https://images.neopets.example/biology_#{@remote_id}.swf",
|
||||
zone: zone,
|
||||
body_id: body_id,
|
||||
zones_restrict: zones_restrict
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to create a SwfAsset for items (object layers)
|
||||
def build_item_asset(zone, body_id:, zones_restrict: "")
|
||||
@remote_id = (@remote_id || 0) + 1
|
||||
SwfAsset.create!(
|
||||
type: "object",
|
||||
remote_id: @remote_id,
|
||||
url: "https://images.neopets.example/object_#{@remote_id}.swf",
|
||||
zone: zone,
|
||||
body_id: body_id,
|
||||
zones_restrict: zones_restrict
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to create an item with specific swf_assets
|
||||
def build_item(name, swf_assets: [])
|
||||
item = Item.create!(
|
||||
name: name,
|
||||
description: "Test item",
|
||||
thumbnail_url: "https://images.neopets.example/thumbnail.png",
|
||||
rarity: "Common",
|
||||
price: 100,
|
||||
zones_restrict: "",
|
||||
species_support_ids: ""
|
||||
)
|
||||
swf_assets.each do |asset|
|
||||
ParentSwfAssetRelationship.create!(
|
||||
parent: item,
|
||||
swf_asset: asset
|
||||
)
|
||||
end
|
||||
item
|
||||
end
|
||||
|
||||
describe "#visible_layers" do
|
||||
before do
|
||||
# Clean up any existing pet types to avoid conflicts
|
||||
PetType.destroy_all
|
||||
|
||||
# Create a basic pet type for testing
|
||||
@pet_type = PetType.create!(
|
||||
species: species(:acara),
|
||||
color: colors(:blue),
|
||||
body_id: 1,
|
||||
created_at: Time.new(2005)
|
||||
)
|
||||
end
|
||||
|
||||
context "basic layer composition" do
|
||||
it "returns pet layers when no items are worn" do
|
||||
# Create biology assets for the pet
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
body = build_biology_asset(zones(:body), body_id: 1)
|
||||
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head, body])
|
||||
outfit = Outfit.new(pet_state: pet_state)
|
||||
|
||||
layers = outfit.visible_layers
|
||||
|
||||
expect(layers).to contain_exactly(head, body)
|
||||
end
|
||||
|
||||
it "returns pet layers and item layers when items are worn" do
|
||||
# Create pet layers
|
||||
head = build_biology_asset(zones(:head), body_id: 1)
|
||||
body = build_biology_asset(zones(:body), body_id: 1)
|
||||
pet_state = build_pet_state(@pet_type, swf_assets: [head, body])
|
||||
|
||||
# Create item layers
|
||||
hat_asset = build_item_asset(zones(: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
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
require_relative '../../rails_helper'
|
||||
require_relative '../../support/mocks/custom_pets'
|
||||
require_relative '../../support/mocks/nc_mall'
|
||||
|
||||
RSpec.describe Pet::AutoModeling, type: :model do
|
||||
fixtures :colors, :species, :zones
|
||||
|
||||
# Set up a Purple Chia pet type (body_id 212) for testing
|
||||
let!(:pet_type) do
|
||||
PetType.create!(
|
||||
species_id: Species.find_by_name!("chia").id,
|
||||
color_id: Color.find_by_name!("purple").id,
|
||||
body_id: 212,
|
||||
image_hash: "purpchia"
|
||||
)
|
||||
end
|
||||
|
||||
# A known compatible item for testing (exists in mock data)
|
||||
let(:compatible_item) do
|
||||
Item.create!(
|
||||
id: 71706,
|
||||
name: "On the Roof Background",
|
||||
description: "Who is that on the roof?! Could it be...?",
|
||||
thumbnail_url: "https://images.neopets.com/items/gif_roof_onthe_fg.gif",
|
||||
rarity: "Special",
|
||||
rarity_index: 101,
|
||||
price: 0,
|
||||
zones_restrict: "0000000000000000000000000000000000000000000000000000"
|
||||
)
|
||||
end
|
||||
|
||||
describe ".model_item_on_body" do
|
||||
context "when item is compatible with the body" do
|
||||
let(:item) { compatible_item }
|
||||
|
||||
it "returns :modeled" do
|
||||
result = Pet::AutoModeling.model_item_on_body(item, 212)
|
||||
expect(result).to eq :modeled
|
||||
end
|
||||
|
||||
it "creates SwfAsset records for the item" do
|
||||
expect {
|
||||
Pet::AutoModeling.model_item_on_body(item, 212)
|
||||
}.to change { SwfAsset.where(type: "object").count }.by(1)
|
||||
end
|
||||
|
||||
it "associates the SwfAsset with the item" do
|
||||
Pet::AutoModeling.model_item_on_body(item, 212)
|
||||
item.reload
|
||||
|
||||
asset = item.swf_assets.find_by(remote_id: 410722)
|
||||
expect(asset).to be_present
|
||||
expect(asset.body_id).to eq 0 # This item fits all bodies
|
||||
expect(asset.zone_id).to eq 3
|
||||
end
|
||||
end
|
||||
|
||||
context "when item is not in the response" do
|
||||
let(:item) do
|
||||
# Create an item that won't be in our mock response
|
||||
Item.create!(
|
||||
id: 99999,
|
||||
name: "Nonexistent Item",
|
||||
description: "This item doesn't exist in the mock",
|
||||
thumbnail_url: "https://example.com/item.gif",
|
||||
rarity: "Special",
|
||||
rarity_index: 101,
|
||||
price: 0,
|
||||
zones_restrict: "0000000000000000000000000000000000000000000000000000"
|
||||
)
|
||||
end
|
||||
|
||||
it "returns :not_compatible" do
|
||||
result = Pet::AutoModeling.model_item_on_body(item, 212)
|
||||
expect(result).to eq :not_compatible
|
||||
end
|
||||
end
|
||||
|
||||
context "when no PetType exists for the body_id" do
|
||||
let(:item) { compatible_item }
|
||||
|
||||
it "raises NoPetTypeForBody" do
|
||||
expect {
|
||||
Pet::AutoModeling.model_item_on_body(item, 99999)
|
||||
}.to raise_error(Pet::AutoModeling::NoPetTypeForBody) do |error|
|
||||
expect(error.body_id).to eq 99999
|
||||
expect(error.message).to include "99999"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
require_relative '../rails_helper'
|
||||
|
||||
RSpec.describe User do
|
||||
describe '.top_contributors_for' do
|
||||
let!(:user1) { create_user('Alice') }
|
||||
let!(:user2) { create_user('Bob') }
|
||||
let!(:user3) { create_user('Charlie') }
|
||||
|
||||
context 'with all_time timeframe' do
|
||||
it 'uses the denormalized points column' do
|
||||
user1.update!(points: 100)
|
||||
user2.update!(points: 50)
|
||||
user3.update!(points: 0)
|
||||
|
||||
results = User.top_contributors_for(:all_time)
|
||||
expect(results.map(&:id)).to eq([user1.id, user2.id])
|
||||
expect(results.first.points).to eq(100)
|
||||
expect(results.second.points).to eq(50)
|
||||
end
|
||||
|
||||
it 'excludes users with zero points' do
|
||||
user1.update!(points: 100)
|
||||
user2.update!(points: 0)
|
||||
|
||||
results = User.top_contributors_for(:all_time)
|
||||
expect(results).not_to include(user2)
|
||||
end
|
||||
|
||||
it 'orders by points descending' do
|
||||
user1.update!(points: 50)
|
||||
user2.update!(points: 100)
|
||||
user3.update!(points: 75)
|
||||
|
||||
results = User.top_contributors_for(:all_time)
|
||||
expect(results.map(&:id)).to eq([user2.id, user3.id, user1.id])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with this_week timeframe' do
|
||||
let(:item) { create_item }
|
||||
|
||||
before do
|
||||
# Create contributions from this week
|
||||
create_contribution(user1, item, 3.days.ago) # 3 points
|
||||
create_contribution(user1, item, 2.days.ago) # 3 points
|
||||
|
||||
# Create contributions from last month (should be excluded)
|
||||
create_contribution(user2, item, 1.month.ago) # 3 points (excluded)
|
||||
end
|
||||
|
||||
it 'calculates points from contributions in the last week' do
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results.first).to eq(user1)
|
||||
expect(results.first.period_points).to eq(6)
|
||||
end
|
||||
|
||||
it 'excludes users with no recent contributions' do
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results).not_to include(user2)
|
||||
end
|
||||
|
||||
it 'excludes contributions older than one week' do
|
||||
create_contribution(user3, item, 8.days.ago)
|
||||
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results).not_to include(user3)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with this_month timeframe' do
|
||||
let(:item) { create_item }
|
||||
let(:pet_type) { create_pet_type }
|
||||
|
||||
before do
|
||||
# User 1: contributions from this month
|
||||
create_contribution(user1, item, 15.days.ago) # 3 points
|
||||
create_contribution(user1, pet_type, 20.days.ago) # 15 points
|
||||
|
||||
# User 2: contributions older than one month
|
||||
create_contribution(user2, item, 35.days.ago) # 3 points (excluded)
|
||||
end
|
||||
|
||||
it 'calculates points from contributions in the last month' do
|
||||
results = User.top_contributors_for(:this_month)
|
||||
expect(results.first).to eq(user1)
|
||||
expect(results.first.period_points).to eq(18)
|
||||
end
|
||||
|
||||
it 'excludes contributions older than one month' do
|
||||
results = User.top_contributors_for(:this_month)
|
||||
expect(results).not_to include(user2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with this_year timeframe' do
|
||||
let(:item) { create_item }
|
||||
|
||||
before do
|
||||
# User 1: contributions from this year
|
||||
create_contribution(user1, item, 3.months.ago) # 3 points
|
||||
create_contribution(user1, item, 6.months.ago) # 3 points
|
||||
|
||||
# User 2: contributions older than one year
|
||||
create_contribution(user2, item, 13.months.ago) # 3 points (excluded)
|
||||
end
|
||||
|
||||
it 'calculates points from contributions in the last year' do
|
||||
results = User.top_contributors_for(:this_year)
|
||||
expect(results.first).to eq(user1)
|
||||
expect(results.first.period_points).to eq(6)
|
||||
end
|
||||
|
||||
it 'excludes contributions older than one year' do
|
||||
results = User.top_contributors_for(:this_year)
|
||||
expect(results).not_to include(user2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'point value calculations' do
|
||||
let(:item) { create_item }
|
||||
let(:pet_type) { create_pet_type }
|
||||
let(:alt_style) { create_alt_style }
|
||||
|
||||
it 'assigns 3 points for Item contributions' do
|
||||
create_contribution(user1, item, 1.day.ago)
|
||||
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results.first.period_points).to eq(3)
|
||||
end
|
||||
|
||||
it 'assigns 15 points for PetType contributions' do
|
||||
create_contribution(user1, pet_type, 1.day.ago)
|
||||
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results.first.period_points).to eq(15)
|
||||
end
|
||||
|
||||
it 'assigns 30 points for AltStyle contributions' do
|
||||
create_contribution(user1, alt_style, 1.day.ago)
|
||||
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results.first.period_points).to eq(30)
|
||||
end
|
||||
|
||||
it 'sums multiple contribution types correctly' do
|
||||
create_contribution(user1, item, 1.day.ago) # 3 points
|
||||
create_contribution(user1, pet_type, 2.days.ago) # 15 points
|
||||
create_contribution(user1, alt_style, 3.days.ago) # 30 points
|
||||
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results.first.period_points).to eq(48)
|
||||
end
|
||||
end
|
||||
|
||||
context 'ordering and filtering' do
|
||||
let(:item) { create_item }
|
||||
|
||||
before do
|
||||
# Create various contributions
|
||||
3.times { create_contribution(user1, item, 1.day.ago) } # 9 points
|
||||
5.times { create_contribution(user2, item, 2.days.ago) } # 15 points
|
||||
2.times { create_contribution(user3, item, 3.days.ago) } # 6 points
|
||||
end
|
||||
|
||||
it 'orders by period_points descending' do
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results.map(&:id)).to eq([user2.id, user1.id, user3.id])
|
||||
end
|
||||
|
||||
it 'uses user.id as secondary sort for tied scores' do
|
||||
# Create two users with same points
|
||||
user4 = create_user('Dave')
|
||||
user5 = create_user('Eve')
|
||||
|
||||
create_contribution(user4, item, 1.day.ago) # 3 points
|
||||
create_contribution(user5, item, 1.day.ago) # 3 points
|
||||
|
||||
results = User.top_contributors_for(:this_week).where(id: [user4.id, user5.id])
|
||||
# Should be ordered by user.id ASC when points are tied
|
||||
expect(results.first.id).to be < results.second.id
|
||||
end
|
||||
|
||||
it 'excludes users with zero contributions in period' do
|
||||
# user3 has no contributions this week
|
||||
user4 = create_user('Dave')
|
||||
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results).not_to include(user4)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid timeframe' do
|
||||
it 'raises ArgumentError' do
|
||||
expect { User.top_contributors_by_period(:invalid) }.
|
||||
to raise_error(ArgumentError, /Invalid timeframe/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#period_points' do
|
||||
let(:user) { create_user('Alice') }
|
||||
|
||||
context 'when period_points attribute is set' do
|
||||
it 'returns the calculated period_points' do
|
||||
# Simulate a query that sets period_points
|
||||
user_with_period = User.select('users.*, 42 AS period_points').find(user.id)
|
||||
expect(user_with_period.period_points).to eq(42)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when period_points attribute is not set' do
|
||||
it 'falls back to denormalized points column' do
|
||||
user.update!(points: 100)
|
||||
expect(user.period_points).to eq(100)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Helper methods
|
||||
def create_user(name)
|
||||
auth_user = AuthUser.create!(
|
||||
name: name,
|
||||
email: "#{name.downcase}@example.com",
|
||||
password: 'password123',
|
||||
password_confirmation: 'password123'
|
||||
)
|
||||
User.create!(name: name, remote_id: auth_user.id, auth_server_id: 1)
|
||||
end
|
||||
|
||||
def create_contribution(user, contributed, created_at)
|
||||
Contribution.create!(
|
||||
user: user,
|
||||
contributed: contributed,
|
||||
created_at: created_at
|
||||
)
|
||||
end
|
||||
|
||||
def create_item
|
||||
# Create a minimal item for testing
|
||||
Item.create!(
|
||||
name: "Test Item #{SecureRandom.hex(4)}",
|
||||
description: "Test item",
|
||||
thumbnail_url: "http://example.com/thumb.png",
|
||||
rarity: "",
|
||||
price: 0,
|
||||
zones_restrict: ""
|
||||
)
|
||||
end
|
||||
|
||||
def create_swf_asset
|
||||
# Create a minimal swf_asset for testing
|
||||
zone = Zone.first || Zone.create!(id: 1, label: "Test Zone", plain_label: "Test Zone", type_id: 1)
|
||||
SwfAsset.create!(
|
||||
type: 'object',
|
||||
remote_id: SecureRandom.random_number(100000),
|
||||
url: "http://example.com/test.swf",
|
||||
zone_id: zone.id,
|
||||
body_id: 0
|
||||
)
|
||||
end
|
||||
|
||||
def create_pet_type
|
||||
# Use find_or_create_by to avoid duplicate key errors
|
||||
species = Species.find_or_create_by!(name: "Test Species #{SecureRandom.hex(4)}")
|
||||
color = Color.find_or_create_by!(name: "Test Color #{SecureRandom.hex(4)}")
|
||||
PetType.create!(
|
||||
species_id: species.id,
|
||||
color_id: color.id,
|
||||
body_id: 0
|
||||
)
|
||||
end
|
||||
|
||||
def create_pet_state
|
||||
pet_type = create_pet_type
|
||||
PetState.create!(
|
||||
pet_type: pet_type,
|
||||
swf_asset_ids: []
|
||||
)
|
||||
end
|
||||
|
||||
def create_alt_style
|
||||
# Use find_or_create_by to avoid duplicate key errors
|
||||
species = Species.find_or_create_by!(name: "Test Species #{SecureRandom.hex(4)}")
|
||||
color = Color.find_or_create_by!(name: "Test Color #{SecureRandom.hex(4)}")
|
||||
AltStyle.create!(
|
||||
species_id: species.id,
|
||||
color_id: color.id,
|
||||
body_id: 0,
|
||||
series_name: "Test Series",
|
||||
thumbnail_url: "http://example.com/thumb.png"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -3,14 +3,9 @@ module Neopets::CustomPets
|
|||
DATA_DIR = Pathname.new(__dir__) / "custom_pets"
|
||||
|
||||
def self.fetch_viewer_data(pet_name, ...)
|
||||
# NOTE: Windows doesn't support `@` in filenames, so we use a `scis` directory instead.
|
||||
path = if pet_name.start_with?('@')
|
||||
DATA_DIR / "scis" / "#{pet_name[1..]}.json"
|
||||
else
|
||||
DATA_DIR / "#{pet_name}.json"
|
||||
File.open(DATA_DIR / "#{pet_name}.json") do |file|
|
||||
HashWithIndifferentAccess.new JSON.load(file)
|
||||
end
|
||||
|
||||
File.open(path) { |f| HashWithIndifferentAccess.new JSON.load(f) }
|
||||
end
|
||||
|
||||
def self.fetch_metadata(...)
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
{
|
||||
"custom_pet": {
|
||||
"name": "@mock:m:thyass:39552",
|
||||
"owner": "",
|
||||
"slot": 1.0,
|
||||
"scale": 0.5,
|
||||
"muted": true,
|
||||
"body_id": 212.0,
|
||||
"species_id": 6.0,
|
||||
"color_id": 61.0,
|
||||
"alt_style": false,
|
||||
"alt_color": 61.0,
|
||||
"style_closet_id": null,
|
||||
"biology_by_zone": {
|
||||
"37": {
|
||||
"part_id": 10083.0,
|
||||
"zone_id": 37.0,
|
||||
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/010/10083_8a1111a13f.swf",
|
||||
"manifest": "https://images.neopets.com/cp/bio/data/000/000/010/10083_8a1111a13f/manifest.json",
|
||||
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"15": {
|
||||
"part_id": 11613.0,
|
||||
"zone_id": 15.0,
|
||||
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/011/11613_f7d8d377ab.swf",
|
||||
"manifest": "https://images.neopets.com/cp/bio/data/000/000/011/11613_f7d8d377ab/manifest.json",
|
||||
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"34": {
|
||||
"part_id": 14187.0,
|
||||
"zone_id": 34.0,
|
||||
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/014/14187_0e65c2082f.swf",
|
||||
"manifest": "https://images.neopets.com/cp/bio/data/000/000/014/14187_0e65c2082f/manifest.json",
|
||||
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"33": {
|
||||
"part_id": 14189.0,
|
||||
"zone_id": 33.0,
|
||||
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/014/14189_102e4991e9.swf",
|
||||
"manifest": "https://images.neopets.com/cp/bio/data/000/000/014/14189_102e4991e9/manifest.json",
|
||||
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
},
|
||||
"equipped_by_zone": {},
|
||||
"original_biology": []
|
||||
},
|
||||
"closet_items": {},
|
||||
"object_info_registry": {
|
||||
"39552": {
|
||||
"obj_info_id": 39552.0,
|
||||
"assets_by_zone": {},
|
||||
"zones_restrict": "0000000000000000000000000000000000000000000000000000",
|
||||
"is_compatible": false,
|
||||
"is_paid": true,
|
||||
"thumbnail_url": "https://images.neopets.com/items/mall_springyeyeglasses.gif",
|
||||
"name": "Springy Eye Glasses",
|
||||
"description": "Hey, keep your eyes in your head!",
|
||||
"category": "Clothes",
|
||||
"type": "Clothes",
|
||||
"rarity": "Artifact",
|
||||
"rarity_index": 500.0,
|
||||
"price": 0.0,
|
||||
"weight_lbs": 1.0,
|
||||
"species_support": [3.0],
|
||||
"converted": true
|
||||
}
|
||||
},
|
||||
"object_asset_registry": {}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
{
|
||||
"custom_pet": {
|
||||
"name": "@mock:m:thyass:71706",
|
||||
"owner": "",
|
||||
"slot": 1.0,
|
||||
"scale": 0.5,
|
||||
"muted": true,
|
||||
"body_id": 212.0,
|
||||
"species_id": 6.0,
|
||||
"color_id": 61.0,
|
||||
"alt_style": false,
|
||||
"alt_color": 61.0,
|
||||
"style_closet_id": null,
|
||||
"biology_by_zone": {
|
||||
"37": {
|
||||
"part_id": 10083.0,
|
||||
"zone_id": 37.0,
|
||||
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/010/10083_8a1111a13f.swf",
|
||||
"manifest": "https://images.neopets.com/cp/bio/data/000/000/010/10083_8a1111a13f/manifest.json",
|
||||
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"15": {
|
||||
"part_id": 11613.0,
|
||||
"zone_id": 15.0,
|
||||
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/011/11613_f7d8d377ab.swf",
|
||||
"manifest": "https://images.neopets.com/cp/bio/data/000/000/011/11613_f7d8d377ab/manifest.json",
|
||||
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"34": {
|
||||
"part_id": 14187.0,
|
||||
"zone_id": 34.0,
|
||||
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/014/14187_0e65c2082f.swf",
|
||||
"manifest": "https://images.neopets.com/cp/bio/data/000/000/014/14187_0e65c2082f/manifest.json",
|
||||
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"33": {
|
||||
"part_id": 14189.0,
|
||||
"zone_id": 33.0,
|
||||
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/014/14189_102e4991e9.swf",
|
||||
"manifest": "https://images.neopets.com/cp/bio/data/000/000/014/14189_102e4991e9/manifest.json",
|
||||
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
},
|
||||
"equipped_by_zone": {
|
||||
"3": {
|
||||
"asset_id": 410722.0,
|
||||
"zone_id": 3.0,
|
||||
"closet_obj_id": 0.0
|
||||
}
|
||||
},
|
||||
"original_biology": []
|
||||
},
|
||||
"closet_items": {},
|
||||
"object_info_registry": {
|
||||
"71706": {
|
||||
"obj_info_id": 71706.0,
|
||||
"assets_by_zone": {
|
||||
"3": 410722.0
|
||||
},
|
||||
"zones_restrict": "0000000000000000000000000000000000000000000000000000",
|
||||
"is_compatible": true,
|
||||
"is_paid": false,
|
||||
"thumbnail_url": "https://images.neopets.com/items/gif_roof_onthe_fg.gif",
|
||||
"name": "On the Roof Background",
|
||||
"description": "Who is that on the roof?! Could it be...?",
|
||||
"category": "Special",
|
||||
"type": "Mystical Surroundings",
|
||||
"rarity": "Special",
|
||||
"rarity_index": 101.0,
|
||||
"price": 0.0,
|
||||
"weight_lbs": 1.0,
|
||||
"species_support": [],
|
||||
"converted": true
|
||||
}
|
||||
},
|
||||
"object_asset_registry": {
|
||||
"410722": {
|
||||
"asset_id": 410722.0,
|
||||
"zone_id": 3.0,
|
||||
"asset_url": "https://images.neopets.com/cp/items/swf/000/000/410/410722_3bcd2f5e11.swf",
|
||||
"obj_info_id": 71706.0,
|
||||
"manifest": "https://images.neopets.com/cp/items/data/000/000/410/410722_3bcd2f5e11/manifest.json?v=1706"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"custom_pet": {
|
||||
"name": "@purpchia:99999",
|
||||
"body_id": 212.0,
|
||||
"species_id": 6.0,
|
||||
"color_id": 61.0,
|
||||
"alt_style": false,
|
||||
"alt_color": 61.0,
|
||||
"biology_by_zone": {
|
||||
"15": {
|
||||
"part_id": 11613.0,
|
||||
"zone_id": 15.0,
|
||||
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/011/11613_f7d8d377ab.swf",
|
||||
"manifest": "https://images.neopets.com/cp/bio/data/000/000/011/11613_f7d8d377ab/manifest.json",
|
||||
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
},
|
||||
"equipped_by_zone": [],
|
||||
"original_biology": []
|
||||
},
|
||||
"closet_items": [],
|
||||
"object_info_registry": [],
|
||||
"object_asset_registry": []
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
# We replace Neopets::NCMall.fetch_pet_data_sci with a mocked implementation.
|
||||
module Neopets::NCMall
|
||||
# Mock implementation that generates predictable SCI hashes for testing.
|
||||
# The hash is derived from the pet_sci and item_ids to ensure consistency.
|
||||
def self.fetch_pet_data_sci(pet_sci, item_ids = [])
|
||||
"#{pet_sci}-#{item_ids.sort.join('-')}"
|
||||
end
|
||||
end
|
||||
BIN
vendor/cache/action_text-trix-2.1.16.gem
vendored
BIN
vendor/cache/action_text-trix-2.1.16.gem
vendored
Binary file not shown.
BIN
vendor/cache/actioncable-8.0.2.gem
vendored
Normal file
BIN
vendor/cache/actioncable-8.0.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/actioncable-8.1.2.gem
vendored
BIN
vendor/cache/actioncable-8.1.2.gem
vendored
Binary file not shown.
BIN
vendor/cache/actionmailbox-8.0.2.gem
vendored
Normal file
BIN
vendor/cache/actionmailbox-8.0.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/actionmailbox-8.1.2.gem
vendored
BIN
vendor/cache/actionmailbox-8.1.2.gem
vendored
Binary file not shown.
BIN
vendor/cache/actionmailer-8.0.2.gem
vendored
Normal file
BIN
vendor/cache/actionmailer-8.0.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/actionmailer-8.1.2.gem
vendored
BIN
vendor/cache/actionmailer-8.1.2.gem
vendored
Binary file not shown.
BIN
vendor/cache/actionpack-8.0.2.gem
vendored
Normal file
BIN
vendor/cache/actionpack-8.0.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/actionpack-8.1.2.gem
vendored
BIN
vendor/cache/actionpack-8.1.2.gem
vendored
Binary file not shown.
BIN
vendor/cache/actiontext-8.0.2.gem
vendored
Normal file
BIN
vendor/cache/actiontext-8.0.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/actiontext-8.1.2.gem
vendored
BIN
vendor/cache/actiontext-8.1.2.gem
vendored
Binary file not shown.
BIN
vendor/cache/actionview-8.0.2.gem
vendored
Normal file
BIN
vendor/cache/actionview-8.0.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/actionview-8.1.2.gem
vendored
BIN
vendor/cache/actionview-8.1.2.gem
vendored
Binary file not shown.
BIN
vendor/cache/activejob-8.0.2.gem
vendored
Normal file
BIN
vendor/cache/activejob-8.0.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/activejob-8.1.2.gem
vendored
BIN
vendor/cache/activejob-8.1.2.gem
vendored
Binary file not shown.
BIN
vendor/cache/activemodel-8.0.2.gem
vendored
Normal file
BIN
vendor/cache/activemodel-8.0.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/activemodel-8.1.2.gem
vendored
BIN
vendor/cache/activemodel-8.1.2.gem
vendored
Binary file not shown.
BIN
vendor/cache/activerecord-8.0.2.gem
vendored
Normal file
BIN
vendor/cache/activerecord-8.0.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/activerecord-8.1.2.gem
vendored
BIN
vendor/cache/activerecord-8.1.2.gem
vendored
Binary file not shown.
BIN
vendor/cache/activestorage-8.0.2.gem
vendored
Normal file
BIN
vendor/cache/activestorage-8.0.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/activestorage-8.1.2.gem
vendored
BIN
vendor/cache/activestorage-8.1.2.gem
vendored
Binary file not shown.
BIN
vendor/cache/activesupport-8.0.2.gem
vendored
Normal file
BIN
vendor/cache/activesupport-8.0.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/activesupport-8.1.2.gem
vendored
BIN
vendor/cache/activesupport-8.1.2.gem
vendored
Binary file not shown.
BIN
vendor/cache/addressable-2.8.7.gem
vendored
Normal file
BIN
vendor/cache/addressable-2.8.7.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/addressable-2.8.8.gem
vendored
BIN
vendor/cache/addressable-2.8.8.gem
vendored
Binary file not shown.
BIN
vendor/cache/ast-2.4.3.gem
vendored
Normal file
BIN
vendor/cache/ast-2.4.3.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/async-2.27.0.gem
vendored
Normal file
BIN
vendor/cache/async-2.27.0.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