Compare commits

..

4 commits

Author SHA1 Message Date
f311c92dbb docs: add deployment checklist for database consolidation
IMPORTANT: This migration is BLOCKED until Impress 2020 is retired.

Created comprehensive deployment guide documenting:
- Why this migration is blocked (Impress 2020 uses openneo_id directly)
- Two paths forward: retire Impress 2020 (recommended) or coordinated update
- Complete step-by-step deployment checklist for when ready
- Rollback procedures
- Risk assessment and mitigations
- Success criteria and timeline estimates

This ensures we don't accidentally deploy this change before addressing
the Impress 2020 dependency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 07:07:57 +00:00
9ba94f9f4b chore: document legacy openneo_id migrations and update references
This commit completes the database consolidation cleanup by documenting
the historical migrations and updating all references to reflect the
single-database architecture.

Changes:
- db/openneo_id_migrate/README.md: Created comprehensive documentation
  explaining the history of the separate database and why these migrations
  are preserved but no longer runnable
- db/openneo_id_schema.rb: Deleted (no longer needed)
- README.md: Updated to reflect single-database architecture
  - Removed mentions of "two databases"
  - Updated "OpenNeo ID Database" section to "Authentication Architecture"
  - Added reference to historical context in db/openneo_id_migrate/README.md
- deploy/setup.yml: Removed openneo_id database creation and privileges
  for future deployments
- db/migrate/20240401124200_increase_username_length.rb: Updated comment
  to note this was historically paired with an openneo_id migration

The codebase now fully reflects the consolidated single-database architecture.
The legacy migration files in db/openneo_id_migrate/ are preserved for
historical reference only.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 07:02:43 +00:00
2c21269a16 feat: migrate AuthUser to main database
This commit completes the migration to consolidate the openneo_id database
into the main openneo_impress database.

Changes:
- AuthUser: Changed from AuthRecord to ApplicationRecord
- AuthUser: Removed explicit table_name (Rails infers 'auth_users')
- AuthUser: Removed all temporary write lock code
- AuthUser: Added TODO comment about future table merge opportunity
- User: Added TODO comment about simplifying remote_id relationship
- AuthRecord: Deleted (no longer needed)
- ApplicationController: Removed temporary rescue_from handler
- database.yml: Removed openneo_id database configuration entirely
- database.yml: Simplified from multi-database (primary:) to single-database structure

The application now runs as a single-database Rails app. The auth_users table
lives in the main openneo_impress database alongside the users table, with
the remote_id relationship preserved.

Next steps for production:
1. Deploy Phase 1 (write lock)
2. Run the CopyAuthUsersTableToMainDatabase migration
3. Deploy this commit (Phase 2)
4. Verify everything works
5. Drop the openneo_id database

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 06:55:11 +00:00
604a8667cf feat: add write lock for AuthUser migration preparation
This commit implements a temporary write lock on the AuthUser model to
prepare for consolidating the openneo_id database into the main database.

Changes:
- AuthUser: Added before_save/before_destroy callbacks that raise
  TemporarilyReadOnly exception to prevent any writes
- AuthUser: Override update_tracked_fields! to silently skip Devise login
  tracking (prevents crashes during login while maintaining read access)
- ApplicationController: Added rescue_from handler for TemporarilyReadOnly
  that redirects to root with a friendly maintenance message
- Migration: Created CopyAuthUsersTableToMainDatabase to copy
  openneo_id.users → openneo_impress.auth_users (preserves all IDs)

This allows us to run the data copy migration in production while:
- Keeping existing users able to log in and use the site
- Blocking new registrations, settings changes, and NeoPass connections
- Losing some login tracking data (acceptable tradeoff)

Next step: Deploy this, then run the migration in production while the
table is stable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 06:50:55 +00:00
307 changed files with 1007 additions and 3087 deletions

View file

@ -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
View file

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

29
Gemfile
View file

@ -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

View file

@ -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)

View file

@ -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)
---

View file

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

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -13,26 +13,7 @@ class OutfitsController < ApplicationController
end
def edit
respond_to do |format|
format.html { render "outfits/edit", layout: false }
format.png do
@outfit = build_outfit_from_wardrobe_params
if @outfit.valid?
renderer = OutfitImageRenderer.new(@outfit)
png_data = renderer.render
if png_data
send_data png_data, type: "image/png", disposition: "inline",
filename: "outfit.png"
expires_in 1.day, public: true
else
head :not_found
end
else
head :bad_request
end
end
end
render "outfits/edit", layout: false
end
def index
@ -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])

View file

@ -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

View file

@ -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)
" (&times;#{count})".html_safe if count > 1

View file

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

View file

@ -1,5 +0,0 @@
class AuthRecord < ApplicationRecord
self.abstract_class = true
connects_to database: {reading: :openneo_id, writing: :openneo_id}
end

View file

@ -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,

View file

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

View file

@ -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

View file

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

View file

@ -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? &&

View file

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

View file

@ -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)

View file

@ -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
View file

@ -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"

View file

@ -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")

View file

@ -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
View file

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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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.

View file

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

View file

@ -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}"

View file

@ -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}"

View file

@ -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

View file

@ -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

View file

@ -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

View 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.

View file

@ -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

View file

@ -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"

View file

@ -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"

View file

@ -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:

View file

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

View file

@ -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`

View file

@ -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

View file

@ -1,82 +0,0 @@
require "vips"
class OutfitImageRenderer
CANVAS_SIZE = 600
def initialize(outfit)
@outfit = outfit
end
def render
layers = @outfit.visible_layers
# Filter out layers without image URLs
layers_with_images = layers.select(&:image_url?)
return nil if layers_with_images.empty?
# Fetch all layer images in parallel
image_data_by_layer = fetch_layer_images(layers_with_images)
# Create transparent canvas in sRGB colorspace
canvas = Vips::Image.black(CANVAS_SIZE, CANVAS_SIZE, bands: 4)
canvas = canvas.new_from_image([0, 0, 0, 0])
canvas = canvas.copy(interpretation: :srgb)
# Composite each layer onto the canvas
layers_with_images.each do |layer|
image_data = image_data_by_layer[layer]
next unless image_data
begin
layer_image = Vips::Image.new_from_buffer(image_data, "")
# Resize the layer to fit the canvas size
# All layer images are square, but may not be CANVAS_SIZE x CANVAS_SIZE
# We need to resize them to exactly CANVAS_SIZE x CANVAS_SIZE
if layer_image.width != CANVAS_SIZE || layer_image.height != CANVAS_SIZE
layer_image = layer_image.resize(
CANVAS_SIZE.to_f / layer_image.width,
vscale: CANVAS_SIZE.to_f / layer_image.height
)
end
# Composite this layer onto the canvas at (0, 0)
# No offset needed since the layer is now exactly canvas-sized
canvas = canvas.composite([layer_image], :over)
rescue Vips::Error => e
# Log and skip layers that fail to load/composite
Rails.logger.warn "Failed to composite layer #{layer.id} (#{layer.image_url}): #{e.message}"
next
end
end
# Return PNG data
canvas.write_to_buffer(".png")
end
private
def fetch_layer_images(layers)
image_data_by_layer = {}
DTIRequests.load_many(max_at_once: 10) do |semaphore|
layers.each do |layer|
semaphore.async do
begin
response = DTIRequests.get(layer.image_url)
if response.success?
image_data_by_layer[layer] = response.read
else
Rails.logger.warn "Failed to fetch image for layer #{layer.id} (#{layer.image_url}): HTTP #{response.status}"
end
rescue => e
Rails.logger.warn "Error fetching image for layer #{layer.id} (#{layer.image_url}): #{e.message}"
end
end
end
image_data_by_layer
end
end
end

View file

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

View file

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

View file

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

View file

@ -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 youre the application owner check the logs for more information.</p>
</article>
</main>

View file

@ -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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

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

View file

@ -1,240 +0,0 @@
require 'webmock/rspec'
require_relative '../rails_helper'
RSpec.describe OutfitImageRenderer do
fixtures :zones, :colors, :species
# Helper to load a fixture image
def load_fixture_image(filename)
path = Rails.root.join('spec', 'fixtures', 'outfit_images', filename)
File.read(path)
end
# Helper to create a pet state with specific swf_assets
def build_pet_state(pet_type, pose: "HAPPY_FEM", swf_assets: [])
pet_state = PetState.create!(
pet_type: pet_type,
pose: pose,
swf_asset_ids: swf_assets.empty? ? [0] : swf_assets.map(&:id)
)
pet_state.swf_assets = swf_assets
pet_state
end
# Helper to create a SwfAsset for biology (pet layers)
def build_biology_asset(zone, body_id:)
@remote_id = (@remote_id || 0) + 1
SwfAsset.create!(
type: "biology",
remote_id: @remote_id,
url: "https://images.neopets.example/biology_#{@remote_id}.swf",
zone: zone,
body_id: body_id,
zones_restrict: "",
has_image: true
)
end
# Helper to create a SwfAsset for items (object layers)
def build_item_asset(zone, body_id:)
@remote_id = (@remote_id || 0) + 1
SwfAsset.create!(
type: "object",
remote_id: @remote_id,
url: "https://images.neopets.example/object_#{@remote_id}.swf",
zone: zone,
body_id: body_id,
zones_restrict: "",
has_image: true
)
end
# Helper to create an item with specific swf_assets
def build_item(name, swf_assets: [])
item = Item.create!(
name: name,
description: "Test item",
thumbnail_url: "https://images.neopets.example/thumbnail.png",
rarity: "Common",
price: 100,
zones_restrict: "",
species_support_ids: ""
)
swf_assets.each do |asset|
ParentSwfAssetRelationship.create!(
parent: item,
swf_asset: asset
)
end
item
end
before do
PetType.destroy_all
@pet_type = PetType.create!(
species: species(:acara),
color: colors(:blue),
body_id: 1,
created_at: Time.new(2005)
)
end
describe "#render" do
context "with a simple outfit" do
it "composites biology and item layers into a single PNG" do
# Load fixture images
acara_png = load_fixture_image('Blue Acara.png')
hat_png = load_fixture_image('Hat.png')
expected_composite_png = load_fixture_image('Blue Acara With Hat.png')
# Create biology and item assets
biology_asset = build_biology_asset(zones(:head), body_id: 1)
item_asset = build_item_asset(zones(:hat1), body_id: 1)
# Stub HTTP requests for the actual image URLs that will be generated
stub_request(:get, biology_asset.image_url).
to_return(body: acara_png, status: 200)
stub_request(:get, item_asset.image_url).
to_return(body: hat_png, status: 200)
# Build outfit
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
item = build_item("Test Hat", swf_assets: [item_asset])
outfit = Outfit.new(
pet_state: pet_state,
worn_items: [item]
)
# Render
renderer = OutfitImageRenderer.new(outfit)
result = renderer.render
# Verify we got PNG data back
expect(result).not_to be_nil
expect(result).to be_a(String)
expect(result[0..7]).to eq("\x89PNG\r\n\x1A\n".b) # PNG magic bytes
# Verify the result is a valid 600x600 PNG
result_image = Vips::Image.new_from_buffer(result, "")
expect(result_image.width).to eq(600)
expect(result_image.height).to eq(600)
# Verify the composite matches the expected image pixel-perfectly
expected_image = Vips::Image.new_from_buffer(expected_composite_png, "")
# Calculate the absolute difference between images
diff = (result_image - expected_image).abs
max_diff = diff.max
# Allow a small tolerance for minor encoding/compositing differences
# The expected image was generated with a different method, so we expect
# very close but not necessarily pixel-perfect matches
tolerance = 2
if max_diff > tolerance
debug_path = Rails.root.join('tmp', 'test_render_result.png')
result_image.write_to_file(debug_path.to_s)
fail "Images should match within tolerance of #{tolerance}, but found max difference of #{max_diff}. Actual output saved to #{debug_path}"
end
end
end
context "when a layer image fails to load" do
it "skips the failed layer and continues" do
hat_png = load_fixture_image('Hat.png')
biology_asset = build_biology_asset(zones(:head), body_id: 1)
item_asset = build_item_asset(zones(:hat1), body_id: 1)
# Stub one successful request and one failure
stub_request(:get, biology_asset.image_url).
to_return(status: 404)
stub_request(:get, item_asset.image_url).
to_return(body: hat_png, status: 200)
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
item = build_item("Test Hat", swf_assets: [item_asset])
outfit = Outfit.new(
pet_state: pet_state,
worn_items: [item]
)
renderer = OutfitImageRenderer.new(outfit)
result = renderer.render
# Should still render successfully with just the one layer
expect(result).not_to be_nil
expect(result[0..7]).to eq("\x89PNG\r\n\x1A\n".b)
end
end
context "when no layers have images" do
it "returns nil" do
# Create an asset but stub image_url to return nil
biology_asset = build_biology_asset(zones(:head), body_id: 1)
allow_any_instance_of(SwfAsset).to receive(:image_url?).and_return(false)
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
outfit = Outfit.new(pet_state: pet_state)
renderer = OutfitImageRenderer.new(outfit)
result = renderer.render
expect(result).to be_nil
end
end
it "resizes all layers to 600x600 before compositing" do
# Load a 1200x1200 item layer (real-world case from Neopets)
item_1200_png = load_fixture_image('Cape.png')
acara_600_png = load_fixture_image('Blue Acara.png')
expected_composite_png = load_fixture_image('Blue Acara With Cape.png')
# Create assets
biology_asset = build_biology_asset(zones(:head), body_id: 1)
item_asset = build_item_asset(zones(:hat1), body_id: 1)
# Stub HTTP requests
stub_request(:get, biology_asset.image_url).
to_return(body: acara_600_png, status: 200)
stub_request(:get, item_asset.image_url).
to_return(body: item_1200_png, status: 200)
# Build outfit
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
item = build_item("Test Item", swf_assets: [item_asset])
outfit = Outfit.new(
pet_state: pet_state,
worn_items: [item]
)
# Render
renderer = OutfitImageRenderer.new(outfit)
result = renderer.render
# Verify we got valid PNG data
expect(result).not_to be_nil
expect(result).to be_a(String)
expect(result[0..7]).to eq("\x89PNG\r\n\x1A\n".b)
# Verify the result is exactly 600x600
result_image = Vips::Image.new_from_buffer(result, "")
expect(result_image.width).to eq(600)
expect(result_image.height).to eq(600)
# Verify the composite matches the expected image pixel-perfectly
expected_image = Vips::Image.new_from_buffer(expected_composite_png, "")
# Calculate the absolute difference between images
diff = (result_image - expected_image).abs
max_diff = diff.max
# Allow a small tolerance for minor encoding/compositing differences
tolerance = 2
if max_diff > tolerance
debug_path = Rails.root.join('tmp', 'test_render_1200_result.png')
result_image.write_to_file(debug_path.to_s)
fail "Images should match within tolerance of #{tolerance}, but found max difference of #{max_diff}. Actual output saved to #{debug_path}"
end
end
end
end

View file

@ -1,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

View file

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

View file

@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/addressable-2.8.7.gem vendored Normal file

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

BIN
vendor/cache/async-2.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