Compare commits

..

No commits in common. "main" and "modeling-tests" have entirely different histories.

549 changed files with 1949 additions and 13514 deletions

View file

@ -1,3 +1,15 @@
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version FROM mcr.microsoft.com/devcontainers/ruby:1-3.1-bullseye
ARG RUBY_VERSION=3.4.5
FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION # Default value to allow debug server to serve content over GitHub Codespace's port forwarding service
# The value is a comma-separated list of allowed domains
ENV RAILS_DEVELOPMENT_HOSTS=".githubpreview.dev,.preview.app.github.dev,.app.github.dev"
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>
# [Optional] Uncomment this line to install additional gems.
# RUN gem install <your-gem-names-here>
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1

View file

@ -0,0 +1,5 @@
CREATE DATABASE openneo_impress;
GRANT ALL PRIVILEGES ON openneo_impress.* TO impress_dev;
CREATE DATABASE openneo_id;
GRANT ALL PRIVILEGES ON openneo_id.* TO impress_dev;

View file

@ -1,36 +1,46 @@
// For format details, see https://containers.dev/implementors/json_reference/. // For format details, see https://aka.ms/devcontainer.json. For config options, see the
// For config options, see the README at: https://github.com/devcontainers/templates/tree/main/src/ruby // README at: https://github.com/devcontainers/templates/tree/main/src/ruby-rails-postgres
{ {
"name": "openneo_impress_items", "name": "Dress to Impress",
"dockerComposeFile": "compose.yaml", "dockerComposeFile": "docker-compose.yml",
"service": "rails-app", "service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"nodeGypDependencies": true,
"version": "lts"
}
},
// Features to add to the dev container. More info: https://containers.dev/features. // Features to add to the dev container. More info: https://containers.dev/features.
"features": { // "features": {},
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/rails/devcontainer/features/mysql-client": {},
"ghcr.io/devcontainers-extra/features/ansible:2": {}
},
"containerEnv": {
"DB_HOST": "mysql"
},
"remoteEnv": {
"IMPRESS_DEPLOY_USER": "${localEnv:USER}"
},
// Use 'forwardPorts' to make a list of ports inside the container available locally. // Use 'forwardPorts' to make a list of ports inside the container available locally.
// This can be used to network with other containers or the host.
"forwardPorts": [3000], "forwardPorts": [3000],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": ".devcontainer/post-create.sh",
"containerEnv": {
// Because the database is hosted on the local network at the hostname `db`,
// we partially override `config/database.yml` to connect to `db`!
"DATABASE_URL_PRIMARY_DEV": "mysql2://db",
"DATABASE_URL_OPENNEO_ID_DEV": "mysql2://db",
"DATABASE_URL_PRIMARY_TEST": "mysql2://db",
"DATABASE_URL_OPENNEO_ID_TEST": "mysql2://db",
// HACK: Out of the box, this dev container doesn't allow installation to
// the default GEM_HOME, because of a weird thing going on with RVM.
// Instead, we set a custom GEM_HOME and GEM_PATH in our home directory!
// https://github.com/devcontainers/templates/issues/188
"GEM_HOME": "~/.rubygems",
"GEM_PATH": "~/.rubygems"
}
// Configure tool-specific properties. // Configure tool-specific properties.
// "customizations": {}, // "customizations": {},
// Uncomment to connect as root instead. More info: https://containers.dev/implementors/json_reference/#remoteUser. // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root", // "remoteUser": "root"
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "bash .devcontainer/setup-ssh-config.sh && bin/setup --skip-server"
} }

View file

@ -1,7 +1,7 @@
name: "openneo_impress_items" version: '3'
services: services:
rails-app: app:
build: build:
context: .. context: ..
dockerfile: .devcontainer/Dockerfile dockerfile: .devcontainer/Dockerfile
@ -12,26 +12,18 @@ services:
# Overrides default command so things don't shut down after the process ends. # Overrides default command so things don't shut down after the process ends.
command: sleep infinity command: sleep infinity
# Uncomment the next line to use a non-root user for all processes. # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
# user: vscode network_mode: service:db
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally. # Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
# (Adding the "ports" property to this file will not forward from a Codespace.) # (Adding the "ports" property to this file will not forward from a Codespace.)
depends_on:
- mysql
environment: db:
DB_USER: root image: mysql:latest
mysql:
image: mariadb:10.6
restart: unless-stopped restart: unless-stopped
environment:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 'true'
volumes: volumes:
- mysql-data:/var/lib/mysql - ./create-db.sql:/docker-entrypoint-initdb.d/create-db.sql
networks: environment:
- default MYSQL_ROOT_PASSWORD: impress_dev
MYSQL_USER: impress_dev
volumes: MYSQL_PASSWORD: impress_dev
mysql-data:

19
.devcontainer/post-create.sh Executable file
View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -e # Quit if any part of this script fails.
# Mark all git repositories as safe to execute, including cached gems.
# NOTE: This would be dangerous to run on a normal multi-user machine,
# but for a dev container that only we use, it should be fine!
git config --global safe.directory '*'
# Install the app's Ruby gem dependencies.
bundle install
# Set up the databases: create the schema, and load in some default data.
bin/rails db:schema:load db:seed
# Install the app's JS dependencies.
yarn install
# Run a first-time build of the app's JS, in development mode.
yarn build:dev

View file

@ -1,28 +0,0 @@
#!/bin/bash
# Creates SSH config for devcontainer to use host's SSH identity
# This allows `ssh impress.openneo.net` to work without hardcoding usernames
mkdir -p ~/.ssh
chmod 700 ~/.ssh
# Only create SSH config if IMPRESS_DEPLOY_USER is explicitly set
if [ -z "$IMPRESS_DEPLOY_USER" ]; then
echo "⚠️ IMPRESS_DEPLOY_USER not set - skipping SSH config creation."
echo " This should be automatically set from your host \$USER environment variable."
echo " See docs/deployment-setup.md for details."
exit 0
fi
cat > ~/.ssh/config <<EOF
# Deployment server config
# Username: ${IMPRESS_DEPLOY_USER}
Host impress.openneo.net
User ${IMPRESS_DEPLOY_USER}
ForwardAgent yes
# Add other host configurations as needed
EOF
chmod 600 ~/.ssh/config
echo "✓ SSH config created. Deployment username: ${IMPRESS_DEPLOY_USER}"

1
.gitignore vendored
View file

@ -5,7 +5,6 @@ tmp/**/*
.env .env
.env.* .env.*
/spec/examples.txt /spec/examples.txt
/.yardoc
/app/assets/builds/* /app/assets/builds/*
!/app/assets/builds/.keep !/app/assets/builds/.keep

View file

@ -1 +1 @@
3.4.5 3.3.5

33
Gemfile
View file

@ -1,7 +1,7 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.4.5' ruby '3.3.5'
gem 'rails', '~> 8.0', '>= 8.0.1' gem 'rails', '~> 7.2', '>= 7.2.1'
# The HTTP server running the Rails instance. # The HTTP server running the Rails instance.
gem 'falcon', '~> 0.48.0' gem 'falcon', '~> 0.48.0'
@ -18,6 +18,7 @@ gem 'sprockets', '~> 4.2'
gem 'haml', '~> 6.1', '>= 6.1.1' gem 'haml', '~> 6.1', '>= 6.1.1'
gem 'sass-rails', '~> 6.0' gem 'sass-rails', '~> 6.0'
gem 'terser', '~> 1.1', '>= 1.1.17' gem 'terser', '~> 1.1', '>= 1.1.17'
gem 'react-rails', '~> 2.7', '>= 2.7.1'
gem 'jsbundling-rails', '~> 1.3' gem 'jsbundling-rails', '~> 1.3'
gem 'turbo-rails', '~> 2.0' gem 'turbo-rails', '~> 2.0'
@ -32,7 +33,7 @@ gem "omniauth_openid_connect", "~> 0.7.1"
gem 'will_paginate', '~> 4.0' gem 'will_paginate', '~> 4.0'
# For translation, both for the site UI and for Neopets data. # For translation, both for the site UI and for Neopets data.
gem 'rails-i18n', '~> 8.0', '>= 8.0.1' gem 'rails-i18n', '~> 7.0', '>= 7.0.7'
gem 'http_accept_language', '~> 2.1', '>= 2.1.1' gem 'http_accept_language', '~> 2.1', '>= 2.1.1'
# For reading and parsing HTML from Neopets.com, like importing Closet pages. # For reading and parsing HTML from Neopets.com, like importing Closet pages.
@ -44,8 +45,7 @@ gem 'sanitize', '~> 6.0', '>= 6.0.2'
# For working with Neopets APIs. # For working with Neopets APIs.
# unstable version of RocketAMF interprets info registry as a hash instead of an array # unstable version of RocketAMF interprets info registry as a hash instead of an array
# Vendored version with Ruby 3.4 ARM compatibility fixes (see vendor/gems/README-RocketAMF.md) gem 'RocketAMF', :git => 'https://github.com/rubyamf/rocketamf.git'
gem 'RocketAMF', path: 'vendor/gems/RocketAMF-1.0.0'
# For preventing too many modeling attempts. # For preventing too many modeling attempts.
gem 'rack-attack', '~> 6.7' gem 'rack-attack', '~> 6.7'
@ -53,22 +53,20 @@ gem 'rack-attack', '~> 6.7'
# For testing emails in development. # For testing emails in development.
gem 'letter_opener', '~> 1.8', '>= 1.8.1', group: :development gem 'letter_opener', '~> 1.8', '>= 1.8.1', group: :development
# For parallel API calls.
gem 'parallel', '~> 1.23'
# For miscellaneous HTTP requests. # For miscellaneous HTTP requests.
gem "httparty", "~> 0.22.0"
gem "addressable", "~> 2.8" gem "addressable", "~> 2.8"
# For advanced batching of many HTTP requests. # For advanced batching of many HTTP requests.
gem "async", "~> 2.17", require: false gem "async", "~> 2.17", require: false
gem "async-http", "~> 0.89.0", require: false gem "async-http", "~> 0.75.0", require: false
gem "thread-local", "~> 1.1", require: false gem "thread-local", "~> 1.1", require: false
# For image processing (outfit PNG rendering).
gem "ruby-vips", "~> 2.2"
# For debugging. # For debugging.
group :development do gem 'web-console', '~> 4.2', group: :development
gem 'debug', '~> 1.9.2'
gem 'web-console', '~> 4.2'
end
# Reduces boot times through caching; required in config/boot.rb # Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '~> 1.16', require: false gem 'bootsnap', '~> 1.16', require: false
@ -86,15 +84,10 @@ gem "sentry-rails", "~> 5.12"
gem "shell", "~> 0.8.1" gem "shell", "~> 0.8.1"
# For workspace autocomplete. # For workspace autocomplete.
group :development do gem "solargraph", "~> 0.50.0", group: :development
gem "solargraph", "~> 0.50.0" gem "solargraph-rails", "~> 1.1", group: :development
gem "solargraph-rails", "~> 1.1"
end
# For automated tests. # For automated tests.
group :development, :test do group :development, :test do
gem "rspec-rails", "~> 7.0" gem "rspec-rails", "~> 7.0"
end end
group :test do
gem "webmock", "~> 3.24"
end

View file

@ -1,142 +1,136 @@
PATH GIT
remote: vendor/gems/RocketAMF-1.0.0 remote: https://github.com/rubyamf/rocketamf.git
revision: 796f591d002b5cf47df436dbcbd6f2ab00e869ed
specs: specs:
RocketAMF (1.0.0.dti1) RocketAMF (1.0.0)
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
action_text-trix (2.1.15) actioncable (7.2.1)
railties actionpack (= 7.2.1)
actioncable (8.1.1) activesupport (= 7.2.1)
actionpack (= 8.1.1)
activesupport (= 8.1.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.1.1) actionmailbox (7.2.1)
actionpack (= 8.1.1) actionpack (= 7.2.1)
activejob (= 8.1.1) activejob (= 7.2.1)
activerecord (= 8.1.1) activerecord (= 7.2.1)
activestorage (= 8.1.1) activestorage (= 7.2.1)
activesupport (= 8.1.1) activesupport (= 7.2.1)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.1.1) actionmailer (7.2.1)
actionpack (= 8.1.1) actionpack (= 7.2.1)
actionview (= 8.1.1) actionview (= 7.2.1)
activejob (= 8.1.1) activejob (= 7.2.1)
activesupport (= 8.1.1) activesupport (= 7.2.1)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.1.1) actionpack (7.2.1)
actionview (= 8.1.1) actionview (= 7.2.1)
activesupport (= 8.1.1) activesupport (= 7.2.1)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) racc
rack (>= 2.2.4, < 3.2)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.1.1) actiontext (7.2.1)
action_text-trix (~> 2.1.15) actionpack (= 7.2.1)
actionpack (= 8.1.1) activerecord (= 7.2.1)
activerecord (= 8.1.1) activestorage (= 7.2.1)
activestorage (= 8.1.1) activesupport (= 7.2.1)
activesupport (= 8.1.1)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.1.1) actionview (7.2.1)
activesupport (= 8.1.1) activesupport (= 7.2.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.1.1) activejob (7.2.1)
activesupport (= 8.1.1) activesupport (= 7.2.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.1.1) activemodel (7.2.1)
activesupport (= 8.1.1) activesupport (= 7.2.1)
activerecord (8.1.1) activerecord (7.2.1)
activemodel (= 8.1.1) activemodel (= 7.2.1)
activesupport (= 8.1.1) activesupport (= 7.2.1)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.1.1) activestorage (7.2.1)
actionpack (= 8.1.1) actionpack (= 7.2.1)
activejob (= 8.1.1) activejob (= 7.2.1)
activerecord (= 8.1.1) activerecord (= 7.2.1)
activesupport (= 8.1.1) activesupport (= 7.2.1)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.1.1) activesupport (7.2.1)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5) connection_pool (>= 2.2.5)
drb drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
json
logger (>= 1.4.2) logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
securerandom (>= 0.3) securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1) addressable (2.8.7)
addressable (2.8.8) public_suffix (>= 2.0.2, < 7.0)
public_suffix (>= 2.0.2, < 8.0)
aes_key_wrap (1.1.0) aes_key_wrap (1.1.0)
ast (2.4.3) ast (2.4.2)
async (2.35.0) async (2.17.0)
console (~> 1.29) console (~> 1.26)
fiber-annotation fiber-annotation
io-event (~> 1.11) io-event (~> 1.6, >= 1.6.5)
metrics (~> 0.12) async-container (0.18.3)
traces (~> 0.18) async (~> 2.10)
async-container (0.27.7) async-http (0.75.0)
async (~> 2.22)
async-http (0.89.0)
async (>= 2.10.2) async (>= 2.10.2)
async-pool (~> 0.9) async-pool (~> 0.7)
io-endpoint (~> 0.14) io-endpoint (~> 0.11)
io-stream (~> 0.6) io-stream (~> 0.4)
metrics (~> 0.12) protocol-http (~> 0.30)
protocol-http (~> 0.49) protocol-http1 (~> 0.20)
protocol-http1 (~> 0.30) protocol-http2 (~> 0.18)
protocol-http2 (~> 0.22) traces (>= 0.10)
traces (~> 0.10) async-http-cache (0.4.4)
async-http-cache (0.4.6)
async-http (~> 0.56) async-http (~> 0.56)
async-pool (0.11.1) async-pool (0.8.1)
async (>= 2.0) async (>= 1.25)
async-service (0.16.0) metrics
traces
async-service (0.12.0)
async async
async-container (~> 0.16) async-container (~> 0.16)
string-format (~> 0.2)
attr_required (1.0.2) 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) backport (1.2.0)
base64 (0.3.0) base64 (0.2.0)
bcrypt (3.1.20) bcrypt (3.1.20)
benchmark (0.5.0) benchmark (0.3.0)
bigdecimal (4.0.1) bigdecimal (3.1.8)
bindata (2.5.1) bindata (2.5.0)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.20.1) bootsnap (1.18.4)
msgpack (~> 1.2) msgpack (~> 1.2)
builder (3.3.0) builder (3.3.0)
childprocess (5.1.0) childprocess (5.1.0)
logger (~> 1.5) logger (~> 1.5)
concurrent-ruby (1.3.6) concurrent-ruby (1.3.4)
connection_pool (3.0.2) connection_pool (2.4.1)
console (1.34.2) console (1.27.0)
fiber-annotation fiber-annotation
fiber-local (~> 1.1) fiber-local (~> 1.1)
json json
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6) crass (1.0.6)
date (3.5.1) csv (3.3.0)
debug (1.9.2) date (3.3.4)
irb (~> 1.10)
reline (>= 0.3.8)
devise (4.9.4) devise (4.9.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
@ -145,19 +139,18 @@ GEM
warden (~> 1.2.3) warden (~> 1.2.3)
devise-encryptable (0.2.0) devise-encryptable (0.2.0)
devise (>= 2.1.0) devise (>= 2.1.0)
diff-lcs (1.6.2) diff-lcs (1.5.1)
dotenv (2.8.1) dotenv (2.8.1)
dotenv-rails (2.8.1) dotenv-rails (2.8.1)
dotenv (= 2.8.1) dotenv (= 2.8.1)
railties (>= 3.2) railties (>= 3.2)
drb (2.2.3) drb (2.2.1)
e2mmap (0.1.0) e2mmap (0.1.0)
email_validator (2.2.4) email_validator (2.2.4)
activemodel activemodel
erb (6.0.1) erubi (1.13.0)
erubi (1.13.1) execjs (2.9.1)
execjs (2.10.0) falcon (0.48.2)
falcon (0.48.6)
async async
async-container (~> 0.18) async-container (~> 0.18)
async-http (~> 0.75) async-http (~> 0.75)
@ -170,107 +163,99 @@ GEM
protocol-http (~> 0.31) protocol-http (~> 0.31)
protocol-rack (~> 0.7) protocol-rack (~> 0.7)
samovar (~> 2.3) samovar (~> 2.3)
faraday (2.14.0) faraday (2.12.0)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.4)
json json
logger logger
faraday-follow_redirects (0.4.0) faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3) faraday (>= 1, < 3)
faraday-net_http (3.4.2) faraday-net_http (3.3.0)
net-http (~> 0.5) net-http
ffi (1.17.2) ffi (1.17.0)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
fiber-annotation (0.2.0) fiber-annotation (0.2.0)
fiber-local (1.1.0) fiber-local (1.1.0)
fiber-storage fiber-storage
fiber-storage (1.0.1) fiber-storage (1.0.0)
globalid (1.3.0) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
haml (6.4.0) haml (6.3.0)
temple (>= 0.8.2) temple (>= 0.8.2)
thor thor
tilt tilt
hashdiff (1.2.1) hashie (5.0.0)
hashie (5.1.0)
logger
http_accept_language (2.1.1) http_accept_language (2.1.1)
i18n (1.14.8) httparty (0.22.0)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.14.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
io-console (0.8.2) io-console (0.7.2)
io-endpoint (0.16.0) io-endpoint (0.13.1)
io-event (1.14.2) io-event (1.6.5)
io-stream (0.11.1) io-stream (0.4.1)
irb (1.16.0) irb (1.14.1)
pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jaro_winkler (1.6.1) jaro_winkler (1.6.0)
jsbundling-rails (1.3.1) jsbundling-rails (1.3.1)
railties (>= 6.0.0) railties (>= 6.0.0)
json (2.18.0) json (2.7.2)
json-jwt (1.17.0) json-jwt (1.16.6)
activesupport (>= 4.2) activesupport (>= 4.2)
aes_key_wrap aes_key_wrap
base64 base64
bindata bindata
faraday (~> 2.0) faraday (~> 2.0)
faraday-follow_redirects faraday-follow_redirects
kramdown (2.5.1) kramdown (2.4.0)
rexml (>= 3.3.9) rexml
kramdown-parser-gfm (1.1.0) kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0) kramdown (~> 2.0)
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.3)
launchy (3.1.1) launchy (3.0.1)
addressable (~> 2.8) addressable (~> 2.8)
childprocess (~> 5.0) childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0) letter_opener (1.10.0)
launchy (>= 2.2, < 4) launchy (>= 2.2, < 4)
lint_roller (1.1.0) localhost (1.3.1)
localhost (1.6.0) logger (1.6.1)
logger (1.7.0) loofah (2.22.0)
loofah (2.25.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.9.0) mail (2.8.1)
logger
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
mapping (1.1.3) mapping (1.1.1)
marcel (1.1.0) marcel (1.0.4)
memory_profiler (1.1.0) memory_profiler (1.1.0)
metrics (0.15.0) metrics (0.10.2)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (6.0.1) mini_portile2 (2.8.7)
prism (~> 1.5) minitest (5.25.1)
msgpack (1.8.0) msgpack (1.7.2)
mysql2 (0.5.7) multi_xml (0.7.1)
bigdecimal bigdecimal (~> 3.1)
net-http (0.9.1) mysql2 (0.5.6)
uri (>= 0.11.1) net-http (0.4.1)
net-imap (0.6.2) uri
net-imap (0.4.16)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
net-protocol net-protocol
net-protocol (0.2.2) net-protocol (0.2.2)
timeout timeout
net-smtp (0.5.1) net-smtp (0.5.0)
net-protocol net-protocol
nio4r (2.7.5) nio4r (2.7.3)
nokogiri (1.18.10-aarch64-linux-gnu) nokogiri (1.16.7)
mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin) omniauth (2.1.2)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4)
omniauth (2.1.4)
hashie (>= 3.4.6) hashie (>= 3.4.6)
logger
rack (>= 2.2.3) rack (>= 2.2.3)
rack-protection rack-protection
omniauth-rails_csrf_protection (1.0.2) omniauth-rails_csrf_protection (1.0.2)
@ -279,7 +264,7 @@ GEM
omniauth_openid_connect (0.7.1) omniauth_openid_connect (0.7.1)
omniauth (>= 1.9, < 3) omniauth (>= 1.9, < 3)
openid_connect (~> 2.2) openid_connect (~> 2.2)
openid_connect (2.3.1) openid_connect (2.3.0)
activemodel activemodel
attr_required (>= 1.0.0) attr_required (>= 1.0.0)
email_validator email_validator
@ -292,118 +277,114 @@ GEM
tzinfo tzinfo
validate_url validate_url
webfinger (~> 2.0) webfinger (~> 2.0)
openssl (3.3.2) openssl (3.2.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
parallel (1.27.0) parallel (1.26.3)
parser (3.3.10.0) parser (3.3.5.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pp (0.6.3) process-metrics (0.3.0)
prettyprint
prettyprint (0.2.0)
prism (1.7.0)
process-metrics (0.8.0)
console (~> 1.8) console (~> 1.8)
json (~> 2) json (~> 2)
samovar (~> 2.1) samovar (~> 2.1)
protocol-hpack (1.5.1) protocol-hpack (1.5.1)
protocol-http (0.56.1) protocol-http (0.37.0)
protocol-http1 (0.35.2) protocol-http1 (0.27.0)
protocol-http (~> 0.22) protocol-http (~> 0.22)
protocol-http2 (0.23.0) protocol-http2 (0.19.1)
protocol-hpack (~> 1.4) protocol-hpack (~> 1.4)
protocol-http (~> 0.47) protocol-http (~> 0.18)
protocol-rack (0.19.0) protocol-rack (0.10.0)
io-stream (>= 0.10) protocol-http (~> 0.37)
protocol-http (~> 0.43)
rack (>= 1.0) rack (>= 1.0)
psych (5.3.1) psych (5.1.2)
date
stringio stringio
public_suffix (7.0.0) public_suffix (6.0.1)
racc (1.8.1) racc (1.8.1)
rack (3.2.4) rack (3.1.7)
rack-attack (6.8.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-mini-profiler (3.3.1) rack-mini-profiler (3.3.1)
rack (>= 1.2.0) rack (>= 1.2.0)
rack-oauth2 (2.3.0) rack-oauth2 (2.2.1)
activesupport activesupport
attr_required attr_required
faraday (~> 2.0) faraday (~> 2.0)
faraday-follow_redirects faraday-follow_redirects
json-jwt (>= 1.11.0) json-jwt (>= 1.11.0)
rack (>= 2.1.0) rack (>= 2.1.0)
rack-protection (4.2.1) rack-protection (4.0.0)
base64 (>= 0.1.0) base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
rack-session (2.1.1) rack-session (2.0.0)
base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.3.1) rackup (2.1.0)
rack (>= 3) rack (>= 3)
rails (8.1.1) webrick (~> 1.8)
actioncable (= 8.1.1) rails (7.2.1)
actionmailbox (= 8.1.1) actioncable (= 7.2.1)
actionmailer (= 8.1.1) actionmailbox (= 7.2.1)
actionpack (= 8.1.1) actionmailer (= 7.2.1)
actiontext (= 8.1.1) actionpack (= 7.2.1)
actionview (= 8.1.1) actiontext (= 7.2.1)
activejob (= 8.1.1) actionview (= 7.2.1)
activemodel (= 8.1.1) activejob (= 7.2.1)
activerecord (= 8.1.1) activemodel (= 7.2.1)
activestorage (= 8.1.1) activerecord (= 7.2.1)
activesupport (= 8.1.1) activestorage (= 7.2.1)
activesupport (= 7.2.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.1.1) railties (= 7.2.1)
rails-dom-testing (2.3.0) rails-dom-testing (2.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.6.0)
loofah (~> 2.21) 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) nokogiri (~> 1.14)
rails-i18n (8.1.0) rails-i18n (7.0.9)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9) railties (>= 6.0.0, < 8)
railties (8.1.1) railties (7.2.1)
actionpack (= 8.1.1) actionpack (= 7.2.1)
activesupport (= 8.1.1) activesupport (= 7.2.1)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.3.1) rake (13.2.1)
rbs (2.8.4) rbs (2.8.4)
rdiscount (2.2.7.3) rdiscount (2.2.7.3)
rdoc (7.0.3) rdoc (6.7.0)
erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort react-rails (2.7.1)
regexp_parser (2.11.3) babel-transpiler (>= 0.7.0)
reline (0.6.3) connection_pool
execjs
railties (>= 3.2)
tilt
regexp_parser (2.9.2)
reline (0.5.10)
io-console (~> 0.5) io-console (~> 0.5)
responders (3.2.0) responders (3.1.1)
actionpack (>= 7.0) actionpack (>= 5.2)
railties (>= 7.0) railties (>= 5.2)
reverse_markdown (2.1.1) reverse_markdown (2.1.1)
nokogiri nokogiri
rexml (3.4.4) rexml (3.3.7)
rspec-core (3.13.6) rspec-core (3.13.2)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.5) rspec-expectations (3.13.3)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-mocks (3.13.7) rspec-mocks (3.13.2)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-rails (7.1.1) rspec-rails (7.0.1)
actionpack (>= 7.0) actionpack (>= 7.0)
activesupport (>= 7.0) activesupport (>= 7.0)
railties (>= 7.0) railties (>= 7.0)
@ -411,26 +392,21 @@ GEM
rspec-expectations (~> 3.13) rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13) rspec-mocks (~> 3.13)
rspec-support (~> 3.13) rspec-support (~> 3.13)
rspec-support (3.13.6) rspec-support (3.13.1)
rubocop (1.82.1) rubocop (1.66.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (>= 3.17.0)
lint_roller (~> 1.1.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.4, < 3.0)
rubocop-ast (>= 1.48.0, < 2.0) rubocop-ast (>= 1.32.2, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.48.0) rubocop-ast (1.32.3)
parser (>= 3.3.7.2) parser (>= 3.3.1.0)
prism (~> 1.4)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-vips (2.3.0) samovar (2.3.0)
ffi (~> 1.12)
logger
samovar (2.4.1)
console (~> 1.0) console (~> 1.0)
mapping (~> 1.0) mapping (~> 1.0)
sanitize (6.1.3) sanitize (6.1.3)
@ -446,11 +422,11 @@ GEM
sprockets (> 3.0) sprockets (> 3.0)
sprockets-rails sprockets-rails
tilt tilt
securerandom (0.4.1) securerandom (0.3.1)
sentry-rails (5.28.1) sentry-rails (5.19.0)
railties (>= 5.0) railties (>= 5.0)
sentry-ruby (~> 5.28.1) sentry-ruby (~> 5.19.0)
sentry-ruby (5.28.1) sentry-ruby (5.19.0)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
shell (0.8.1) shell (0.8.1)
@ -472,45 +448,40 @@ GEM
thor (~> 1.0) thor (~> 1.0)
tilt (~> 2.0) tilt (~> 2.0)
yard (~> 0.9, >= 0.9.24) yard (~> 0.9, >= 0.9.24)
solargraph-rails (1.2.4) solargraph-rails (1.1.0)
activesupport activesupport
solargraph (>= 0.48.0, <= 0.57) solargraph
sprockets (4.2.2) sprockets (4.2.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
logger
rack (>= 2.2.4, < 4) rack (>= 2.2.4, < 4)
sprockets-rails (3.5.2) sprockets-rails (3.5.2)
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
stackprof (0.2.27) stackprof (0.2.26)
string-format (0.2.0) stringio (3.1.1)
stringio (3.2.0)
swd (2.0.3) swd (2.0.3)
activesupport (>= 3) activesupport (>= 3)
attr_required (>= 0.0.5) attr_required (>= 0.0.5)
faraday (~> 2.0) faraday (~> 2.0)
faraday-follow_redirects faraday-follow_redirects
sync (0.5.0) sync (0.5.0)
temple (0.10.4) temple (0.10.3)
terser (1.2.6) terser (1.2.3)
execjs (>= 0.3.0, < 3) execjs (>= 0.3.0, < 3)
thor (1.4.0) thor (1.3.2)
thread-local (1.1.0) thread-local (1.1.0)
tilt (2.6.1) tilt (2.4.0)
timeout (0.6.0) timeout (0.4.1)
traces (0.18.2) traces (0.13.1)
tsort (0.2.0) turbo-rails (2.0.10)
turbo-rails (2.0.20) actionpack (>= 6.0.0)
actionpack (>= 7.1.0) railties (>= 6.0.0)
railties (>= 7.1.0)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0) unicode-display_width (2.6.0)
unicode-emoji (~> 4.1) uri (0.13.1)
unicode-emoji (4.2.0) useragent (0.16.10)
uri (1.1.1)
useragent (0.16.11)
validate_url (1.0.15) validate_url (1.0.15)
activemodel (>= 3.0.0) activemodel (>= 3.0.0)
public_suffix public_suffix
@ -525,36 +496,30 @@ GEM
activesupport activesupport
faraday (~> 2.0) faraday (~> 2.0)
faraday-follow_redirects faraday-follow_redirects
webmock (3.26.1) webrick (1.8.2)
addressable (>= 2.8.0) websocket-driver (0.7.6)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
will_paginate (4.0.1) will_paginate (4.0.1)
yard (0.9.38) yard (0.9.37)
zeitwerk (2.7.4) zeitwerk (2.6.18)
PLATFORMS PLATFORMS
aarch64-linux ruby
arm64-darwin
x86_64-linux
DEPENDENCIES DEPENDENCIES
RocketAMF! RocketAMF!
addressable (~> 2.8) addressable (~> 2.8)
async (~> 2.17) async (~> 2.17)
async-http (~> 0.89.0) async-http (~> 0.75.0)
bootsnap (~> 1.16) bootsnap (~> 1.16)
debug (~> 1.9.2)
devise (~> 4.9, >= 4.9.2) devise (~> 4.9, >= 4.9.2)
devise-encryptable (~> 0.2.0) devise-encryptable (~> 0.2.0)
dotenv-rails (~> 2.8, >= 2.8.1) dotenv-rails (~> 2.8, >= 2.8.1)
falcon (~> 0.48.0) falcon (~> 0.48.0)
haml (~> 6.1, >= 6.1.1) haml (~> 6.1, >= 6.1.1)
http_accept_language (~> 2.1, >= 2.1.1) http_accept_language (~> 2.1, >= 2.1.1)
httparty (~> 0.22.0)
jsbundling-rails (~> 1.3) jsbundling-rails (~> 1.3)
letter_opener (~> 1.8, >= 1.8.1) letter_opener (~> 1.8, >= 1.8.1)
memory_profiler (~> 1.0) memory_profiler (~> 1.0)
@ -563,13 +528,14 @@ DEPENDENCIES
omniauth (~> 2.1) omniauth (~> 2.1)
omniauth-rails_csrf_protection (~> 1.0) omniauth-rails_csrf_protection (~> 1.0)
omniauth_openid_connect (~> 0.7.1) omniauth_openid_connect (~> 0.7.1)
parallel (~> 1.23)
rack-attack (~> 6.7) rack-attack (~> 6.7)
rack-mini-profiler (~> 3.1) rack-mini-profiler (~> 3.1)
rails (~> 8.0, >= 8.0.1) rails (~> 7.2, >= 7.2.1)
rails-i18n (~> 8.0, >= 8.0.1) rails-i18n (~> 7.0, >= 7.0.7)
rdiscount (~> 2.2, >= 2.2.7.1) rdiscount (~> 2.2, >= 2.2.7.1)
react-rails (~> 2.7, >= 2.7.1)
rspec-rails (~> 7.0) rspec-rails (~> 7.0)
ruby-vips (~> 2.2)
sanitize (~> 6.0, >= 6.0.2) sanitize (~> 6.0, >= 6.0.2)
sass-rails (~> 6.0) sass-rails (~> 6.0)
sentry-rails (~> 5.12) sentry-rails (~> 5.12)
@ -583,11 +549,10 @@ DEPENDENCIES
thread-local (~> 1.1) thread-local (~> 1.1)
turbo-rails (~> 2.0) turbo-rails (~> 2.0)
web-console (~> 4.2) web-console (~> 4.2)
webmock (~> 3.24)
will_paginate (~> 4.0) will_paginate (~> 4.0)
RUBY VERSION RUBY VERSION
ruby 3.4.5p51 ruby 3.3.5p100
BUNDLED WITH BUNDLED WITH
2.7.1 2.5.18

View file

@ -1,2 +1,2 @@
web: unset PORT && env RUBY_DEBUG_OPEN=true bin/rails server -b 0.0.0.0 web: unset PORT && env RUBY_DEBUG_OPEN=true bin/rails server
js: yarn dev js: yarn dev

161
README.md
View file

@ -2,163 +2,6 @@
# Dress to Impress # Dress to Impress
Dress to Impress (DTI) is a tool for designing Neopets outfits. Load your pet, browse items, and see how they look together—all with a mobile-friendly interface! Oh! We've been revitalizing the Rails app! Fun!
## Architecture Overview There'll be more to say about it here soon :3
DTI is a Rails application with a React-based outfit editor, backed by MySQL databases and a crowdsourced data collection system.
### 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`)
- **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
### The Impress 2020 Complication
In 2020, we started a NextJS rewrite ("Impress 2020") to modernize the frontend. We've since consolidated back into Rails, but **Impress 2020 still provides essential services**:
- **GraphQL API**: Some outfit appearance data still loads via GraphQL (being migrated to Rails REST APIs)
- **Image generation**: Runs a headless browser to render outfit thumbnails and convert HTML5 assets to PNGs
See [docs/impress-2020-dependencies.md](./docs/impress-2020-dependencies.md) for migration status.
## Key Concepts
### Customization Data Model
The core data model powers outfit rendering and item compatibility. See [docs/customization-architecture.md](./docs/customization-architecture.md) for details.
**Quick summary**:
- `body_id` is the key compatibility constraint (not species or color directly)
- Items have different `swf_assets` (visual layers) for different bodies
- Restrictions are subtractive: start with all layers, hide some based on zone restrictions
- Data is crowdsourced through "modeling" (users loading pets to contribute appearance data)
### Modeling (Crowdsourced Data)
DTI doesn't pre-populate item/pet data. Instead:
1. User loads a pet (via pet name lookup)
2. DTI fetches appearance data from Neopets APIs (legacy Flash/AMF protocol)
3. New `SwfAsset` records and relationships are created
4. Over time, the database learns which items fit which pet bodies
This "self-sustaining" approach means the site stays up-to-date as Neopets releases new content, without manual data entry.
## Directory Map
### Key Application Files
```
app/
├── controllers/
│ ├── outfits_controller.rb # Outfit editor + CRUD
│ ├── items_controller.rb # Item search, pages, and JSON APIs
│ ├── pets_controller.rb # Pet loading (triggers modeling)
│ └── closet_hangers_controller.rb # User item lists ("closets")
├── models/
│ ├── item.rb # Items + compatibility prediction logic
│ ├── pet_type.rb # Species+Color combinations (has body_id)
│ ├── pet_state.rb # Visual variants (pose/gender/mood)
│ ├── swf_asset.rb # Visual layers (biology/object)
│ ├── outfit.rb # Saved outfits + rendering logic (visible_layers)
│ ├── alt_style.rb # Alternative pet appearances (Nostalgic, etc.)
│ └── pet/
│ └── modeling_snapshot.rb # Processes Neopets API data into models
├── services/
│ ├── neopets/
│ │ ├── custom_pets.rb # Neopets AMF/Flash API client (pet data)
│ │ ├── nc_mall.rb # NC Mall item scraping
│ │ └── neopass.rb # NeoPass OAuth integration
│ ├── neopets_media_archive.rb # Local mirror of images.neopets.com
│ └── lebron_nc_values.rb # NC item trading values (external API)
├── javascript/
│ ├── wardrobe-2020/ # React outfit editor (extracted from Impress 2020)
│ │ ├── loaders/ # REST API calls (migrated from GraphQL)
│ │ ├── WardrobePage/ # Main editor UI
│ │ └── components/ # Shared React components
│ └── application.js # Rails asset pipeline entrypoint
└── views/
├── outfits/
│ └── edit.html.haml # Outfit editor page (loads React app)
├── items/
│ └── show.html.haml # Item detail page
└── closet_hangers/
└── index.html.haml # User closet/item lists
```
### Configuration & Docs
```
config/
├── routes.rb # All Rails routes
├── database.yml # Multi-database setup (main + openneo_id)
└── environments/
└── *.rb # Env-specific config (incl. impress_2020_origin)
```
**Documentation:**
- [docs/customization-architecture.md](./docs/customization-architecture.md) - Deep dive into data model & rendering
- [docs/impress-2020-dependencies.md](./docs/impress-2020-dependencies.md) - What still depends on Impress 2020 service
**Tests:**
- `test/` - Test::Unit tests (privacy features)
- `spec/` - RSpec tests (models, services, integrations)
- Coverage is focused on key areas: modeling, prediction logic, external APIs
- Not comprehensive, but thorough for critical behaviors
## Tech Stack
- **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`)
- **Styling**: CSS, Sass (moving toward modern Rails conventions)
- **External Integrations**:
- **Neopets.com**: Legacy Flash/AMF protocol for pet appearance data (modeling)
- **Neopets NC Mall**: Web scraping for NC item availability/pricing
- **NeoPass**: OAuth integration for Neopets account linking
- **Neopets Media Archive**: Local filesystem mirror of `images.neopets.com` (never discards old files)
- **Lebron's NC Values**: Third-party API for NC item trading values ([lebron-values.netlify.app](https://lebron-values.netlify.app))
- **Impress 2020**: GraphQL for some outfit data, image generation service (being phased out)
## Development Notes
### OpenNeo ID Database
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.
**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
### Rails/React Hybrid
Most pages are traditional Rails views using Turbo for interactivity. The **outfit editor** (`/outfits/new`) is a full React app that:
- Loads into a `#wardrobe-2020-root` div
- Uses React Query for data fetching
- Calls both Rails REST APIs (in `loaders/`) and Impress 2020 GraphQL (being migrated)
The goal is to simplify this over time—either consolidate into Rails+Turbo, or commit fully to React. For now, we're in a hybrid state.
## Deployment
- **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**
---
**Project maintained by [@matchu](https://github.com/matchu)** • **[OpenNeo.net](https://openneo.net)**

View file

@ -754,11 +754,6 @@
contactField.val(connection.id); contactField.val(connection.id);
submitContactForm(); submitContactForm();
}, },
error: function (xhr) {
var data = JSON.parse(xhr.responseText);
var fullMessage = data.full_error_messages.join("\n");
alert("Oops, we couldn't save this username!\n\n" + fullMessage);
},
}); });
} }
} else { } else {

View file

@ -1,36 +0,0 @@
class MagicMagnifier extends HTMLElement {
#internals = this.attachInternals();
connectedCallback() {
setTimeout(() => this.#attachLens(), 0);
this.addEventListener("mousemove", this.#onMouseMove);
}
#attachLens() {
const lens = document.createElement("magic-magnifier-lens");
lens.inert = true;
lens.useContent(this.children);
this.appendChild(lens);
}
#onMouseMove(e) {
const lens = this.querySelector("magic-magnifier-lens");
const rect = this.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
this.style.setProperty("--magic-magnifier-x", x + "px");
this.style.setProperty("--magic-magnifier-y", y + "px");
this.#internals.states.add("ready");
}
}
class MagicMagnifierLens extends HTMLElement {
useContent(contentNodes) {
for (const contentNode of contentNodes) {
this.appendChild(contentNode.cloneNode(true));
}
}
}
customElements.define("magic-magnifier", MagicMagnifier);
customElements.define("magic-magnifier-lens", MagicMagnifierLens);

View file

@ -1,46 +0,0 @@
class SupportOutfitViewer extends HTMLElement {
#internals = this.attachInternals();
connectedCallback() {
this.addEventListener("mouseenter", this.#onMouseEnter, { capture: true });
this.addEventListener("mouseleave", this.#onMouseLeave, { capture: true });
this.addEventListener("click", this.#onClick);
this.#internals.states.add("ready");
}
// When a row is hovered, highlight its corresponding outfit viewer layer.
#onMouseEnter(e) {
if (!e.target.matches("tr")) return;
const id = e.target.querySelector("[data-field=id]").innerText;
const layers = this.querySelectorAll(
`outfit-viewer [data-asset-id="${CSS.escape(id)}"]`,
);
for (const layer of layers) {
layer.setAttribute("highlighted", "");
}
}
// When a row is unhovered, unhighlight its corresponding outfit viewer layer.
#onMouseLeave(e) {
if (!e.target.matches("tr")) return;
const id = e.target.querySelector("[data-field=id]").innerText;
const layers = this.querySelectorAll(
`outfit-viewer [data-asset-id="${CSS.escape(id)}"]`,
);
for (const layer of layers) {
layer.removeAttribute("highlighted");
}
}
// When clicking a row, redirect the click to the first link.
#onClick(e) {
const row = e.target.closest("tr");
if (row == null) return;
row.querySelector("[data-field=links] a").click();
}
}
customElements.define("support-outfit-viewer", SupportOutfitViewer);

View file

@ -25,10 +25,6 @@ let numFramesSinceLastLog = 0;
// State for error reporting. // State for error reporting.
let hasLoggedRenderError = false; let hasLoggedRenderError = false;
////////////////////////////////////////////////////
//////// Loading the library and its assets ////////
////////////////////////////////////////////////////
function loadImage(src) { function loadImage(src) {
const image = new Image(); const image = new Image();
image.crossOrigin = "anonymous"; image.crossOrigin = "anonymous";
@ -68,8 +64,8 @@ async function getLibrary() {
// One more loading step as part of loading this library is loading the // One more loading step as part of loading this library is loading the
// images it uses for sprites. // images it uses for sprites.
// //
// NOTE: We also read these from the manifest, and include them in the // TODO: I guess the manifest has these too, so we could put them in preload
// document as preload meta tags, to get them moving faster. // meta tags to get them here faster?
const librarySrcDir = libraryUrl.split("/").slice(0, -1).join("/"); const librarySrcDir = libraryUrl.split("/").slice(0, -1).join("/");
const manifestImages = new Map( const manifestImages = new Map(
library.properties.manifest.map(({ id, src }) => [ library.properties.manifest.map(({ id, src }) => [
@ -100,10 +96,6 @@ async function getLibrary() {
return library; return library;
} }
/////////////////////////////////////
//////// Rendering the movie ////////
/////////////////////////////////////
function buildMovieClip(library) { function buildMovieClip(library) {
let constructorName; let constructorName;
try { try {
@ -159,22 +151,6 @@ function updateCanvasDimensions() {
movieClip.scaleY = internalHeight / library.properties.height; movieClip.scaleY = internalHeight / library.properties.height;
} }
window.addEventListener("resize", () => {
updateCanvasDimensions();
// Redraw the stage with the new dimensions - but with `tickOnUpdate` set
// to `false`, so that we don't advance by a frame. This keeps us
// really-paused if we're paused, and avoids skipping ahead by a frame if
// we're playing.
stage.tickOnUpdate = false;
updateStage();
stage.tickOnUpdate = true;
});
////////////////////////////////////////////////////
//// Monitoring and controlling animation state ////
////////////////////////////////////////////////////
async function startMovie() { async function startMovie() {
// Load the movie's library (from the JS file already run), and use it to // Load the movie's library (from the JS file already run), and use it to
// build a movie clip. // build a movie clip.
@ -298,10 +274,6 @@ function getInitialPlayingStatus() {
} }
} }
//////////////////////////////////////////
//// Syncing with the parent document ////
//////////////////////////////////////////
/** /**
* Recursively scans the given MovieClip (or child createjs node), to see if * Recursively scans the given MovieClip (or child createjs node), to see if
* there are any animated areas. * there are any animated areas.
@ -340,6 +312,18 @@ function sendMessage(message) {
parent.postMessage(message, document.location.origin); parent.postMessage(message, document.location.origin);
} }
window.addEventListener("resize", () => {
updateCanvasDimensions();
// Redraw the stage with the new dimensions - but with `tickOnUpdate` set
// to `false`, so that we don't advance by a frame. This keeps us
// really-paused if we're paused, and avoids skipping ahead by a frame if
// we're playing.
stage.tickOnUpdate = false;
updateStage();
stage.tickOnUpdate = true;
});
window.addEventListener("message", ({ data }) => { window.addEventListener("message", ({ data }) => {
// NOTE: For more sensitive messages, it's important for security to also // NOTE: For more sensitive messages, it's important for security to also
// check the `origin` property of the incoming event. But in this case, I'm // check the `origin` property of the incoming event. But in this case, I'm
@ -355,10 +339,6 @@ window.addEventListener("message", ({ data }) => {
} }
}); });
/////////////////////////////////
//// The actual entry point! ////
/////////////////////////////////
startMovie() startMovie()
.then(() => { .then(() => {
sendStatus(); sendStatus();

View file

@ -2,3 +2,54 @@
width: 300px width: 300px
height: 300px height: 300px
margin: 0 auto margin: 0 auto
.alt-style-form
display: flex
flex-direction: column
gap: 1em
align-items: flex-start
fieldset
width: 100%
display: grid
grid-template-columns: auto 1fr
align-items: center
gap: 1em
> *:nth-child(2n)
width: 40rch
max-width: 100%
box-sizing: border-box
input[type=url]
font-size: .85em
label
font-weight: bold
.thumbnail-field
display: flex
align-items: center
gap: .25em
img
width: 40px
height: 40px
input
flex: 1 0 20ch
.field_with_errors
display: contents
.actions
display: flex
align-items: center
gap: 1em
label
display: flex
align-items: center
gap: .25em
font-size: .85em
font-style: italic

View file

@ -1,13 +1,3 @@
@import "../partials/clean/constants"
// Prefer to break the name at visually appealing points.
.rainbow-pool-list .rainbow-pool-list
.name .name span
text-wrap: balance display: inline-block
// De-emphasize Prismatic styles, in browsers that support it.
.rainbow-pool-filters
select[name="series"]
option[value*=": "]
color: $soft-text-color
font-style: italic

View file

@ -1,50 +0,0 @@
magic-magnifier
display: block
position: relative
// Only show the lens when we are hovering, and the magnifier's X and Y
// coordinates are set. (This ensures the component is running, and has
// received a mousemove event, instead of defaulting to (0, 0).)
magic-magnifier-lens
display: none
// TODO: Once container query support is broader, we can remove the CSS state
// and read for the presence of the X and Y custom properties instead.
&:hover:state(ready)
magic-magnifier-lens
display: block
magic-magnifier-lens
display: block
width: var(--magic-magnifier-lens-width, 100px)
height: var(--magic-magnifier-lens-height, 100px)
overflow: hidden
border-radius: 100%
background: white
border: 2px solid black
box-shadow: 3px 3px 3px rgba(0, 0, 0, .5)
position: absolute
left: var(--magic-magnifier-x, 0px)
top: var(--magic-magnifier-y, 0px)
> *
// Translations are applied in the opposite of the order they're specified.
// So, here's what we're doing:
//
// 1. Translate the content left by --magic-magnifier-x and up by
// --magic-magnifier-y, to align the target location with the lens's
// top-right corner.
// 2. Zoom in by --magic-magnifier-scale.
// 3. Translate the content right by half of --magic-magnifier-lens-width,
// and down by half of --magic-magnifier-lens-height, to align the
// target location with the lens's center.
//
// Note that it *is* possible to specify transforms relative to the center,
// rather than the top-left cornerthis is in fact the default!but that
// gets confusing fast with scale in play. I think this is easier to reason
// about with the top-left corner in terms of math, and center it after the
// fact.
transform: translateX(calc(var(--magic-magnifier-lens-width, 100px) / 2)) translateY(calc(var(--magic-magnifier-lens-height, 100px) / 2)) scale(var(--magic-magnifier-scale, 2)) translateX(calc(-1 * var(--magic-magnifier-x, 0px))) translateY(calc(-1 * var(--magic-magnifier-y, 0px)))
transform-origin: left top

View file

@ -108,18 +108,3 @@ outfit-viewer
&:has(outfit-layer:state(loading)) &:has(outfit-layer:state(loading))
+outfit-viewer-loading +outfit-viewer-loading
// If a layer has the `[highlighted]` attribute, it's brought to the front,
// and other layers are grayed out and blurred. We use this in the support
// outfit viewer, when you hover over a layer.
&:has(outfit-layer[highlighted])
outfit-layer[highlighted]
z-index: 999
// Filter everything behind the bottom-most highlighted layer, using a
// backdrop filter. This gives us the best visual consistency by applying
// effects to the entire backdrop, instead of each layer and then
// re-compositing them.
backdrop-filter: grayscale(1) brightness(2) blur(1px)
& ~ outfit-layer[highlighted]
backdrop-filter: none

View file

@ -1,102 +0,0 @@
@import "../partials/clean/constants"
.support-form
display: flex
flex-direction: column
gap: 1em
align-items: flex-start
.fields
list-style-type: none
display: flex
flex-direction: column
gap: .75em
width: 100%
> li
display: flex
flex-direction: column
gap: .25em
max-width: 60ch
> label, > .field_with_errors label
display: block
font-weight: bold
.field_with_errors
> label
color: $error-color
input[type=text], input[type=url]
border-color: $error-border-color
color: $error-color
&[data-type=radio]
ul
list-style-type: none
&[data-type=radio-grid] // Set the `--num-columns` property to configure!
max-width: none
ul
list-style-type: none
display: grid
grid-template-columns: repeat(var(--num-columns, 1), 1fr)
gap: .25em
li
display: flex
align-items: stretch // Give the bubbles equal heights!
label
display: flex
align-items: center
gap: .5em
padding: .5em 1em
border: 1px solid $soft-border-color
border-radius: 1em
flex: 1 1 auto
input
margin: 0
&:has(:checked)
background: $module-bg-color
border-color: $module-border-color
input[type=text], input[type=url]
width: 100%
min-width: 10ch
box-sizing: border-box
.thumbnail-input
display: flex
align-items: center
gap: .25em
img
width: 40px
height: 40px
fieldset
display: flex
flex-direction: column
gap: .25em
legend
font-weight: bold
.field_with_errors
display: contents
.actions
display: flex
align-items: center
gap: 1em
.go-to-next
display: flex
align-items: center
gap: .25em
font-size: .85em
font-style: italic

View file

@ -31,14 +31,11 @@ body.closet_hangers-index
color: $soft-text-color color: $soft-text-color
margin-bottom: 1em margin-bottom: 1em
margin-left: 2em margin-left: 2em
min-height: $icon-height min-height: image-height("neomail.png")
display: flex
gap: .5em
align-items: center
a a
color: inherit color: inherit
margin-right: .5em
text-decoration: none text-decoration: none
&:hover &:hover
text-decoration: underline text-decoration: underline
@ -47,14 +44,13 @@ body.closet_hangers-index
background: background:
position: left center position: left center
repeat: no-repeat repeat: no-repeat
padding-left: image-width("neomail.png") + 4px
a.neomail, > form a.neomail, > form
background-image: image-url("neomail.png") background-image: image-url("neomail.png")
padding-left: $icon-width + 4px
a.lookup a.lookup
background-image: image-url("lookup.png") background-image: image-url("lookup.png")
padding-left: $icon-width + 4px
select select
width: 10em width: 10em

View file

@ -18,16 +18,6 @@
overflow: hidden overflow: hidden
text-overflow: ellipsis text-overflow: ellipsis
td[data-is-same-as-prev]
// Visually hidden
clip: rect(0 0 0 0)
clip-path: inset(50%)
height: 1px
overflow: hidden
position: absolute
white-space: nowrap
width: 1px
.trade-list-names .trade-list-names
list-style: none list-style: none

View file

@ -107,10 +107,10 @@
a a
color: inherit color: inherit
.nc-trade-guide-info-link .owls-info-link
cursor: help cursor: help
.nc-trade-guide-info-label .owls-info-label
text-decoration-line: underline text-decoration-line: underline
text-decoration-style: dotted text-decoration-style: dotted

View file

@ -1,7 +1,6 @@
@import "clean/mixins"
=context-button =context-button
+awesome-button +awesome-button
+awesome-button-color(#aaaaaa) +awesome-button-color(#aaaaaa)
+opacity(0.9) +opacity(0.9)
font-size: 80% font-size: 80%

View file

@ -67,21 +67,14 @@
background: #FEEBC8 background: #FEEBC8
color: #7B341E color: #7B341E
.support-form
grid-area: support
font-size: 85%
text-align: left
.user-lists-info .user-lists-info
grid-area: lists grid-area: lists
font-size: 85% font-size: 85%
text-align: left text-align: left
display: flex .user-lists-form-opener
gap: 1em &::after
content: " "
a::after
content: " "
.user-lists-form .user-lists-form
background: $background-color background: $background-color

View file

@ -1,15 +1,25 @@
support-outfit-viewer @import "../partials/clean/constants"
margin-block: 1em
.fields li[data-type=radio-grid] outfit-viewer
--num-columns: 3 margin: 0 auto
.reference-link .pose-options
display: flex list-style-type: none
align-items: center display: grid
gap: .5em grid-template-columns: 1fr 1fr 1fr
padding-inline: .5em gap: .25em
img label
height: 2em display: flex
width: auto align-items: center
gap: .5em
padding: .5em 1em
border: 1px solid $soft-border-color
border-radius: 1em
input
margin: 0
&:has(:checked)
background: $module-bg-color
border-color: $module-border-color

View file

@ -1,85 +0,0 @@
@import "../partials/clean/constants"
support-outfit-viewer
display: flex
gap: 2em
flex-wrap: wrap
justify-content: center
outfit-viewer
flex: 0 0 auto
border: 1px solid $module-border-color
border-radius: 1em
.outfit-viewer-controls
margin-block: .5em
isolation: isolate // Avoid z-index weirdness with our buttons vs the lens
display: flex
align-items: center
justify-content: center
gap: .5em
font-size: .85em
fieldset
display: contents
legend
font-weight: bold
&::after
content: ":"
label
display: flex
align-items: center
gap: .25em
input[type=radio]
margin: 0
.outfit-viewer-area
> [data-format=png]
display: none
&:has(input[value=png]:checked)
.outfit-viewer-area
> [data-format=svg]
display: none
> [data-format=png]
display: block
> table
flex: 0 0 auto
border-collapse: collapse
table-layout: fixed
border-radius: .5em
th, td
border: 1px solid $module-border-color
font-size: .85em
padding: .25em .5em
text-align: left
> tbody
[data-field=links]
ul
list-style-type: none
display: flex
gap: .5em
// Once the component is ready, add some hints about potential interactions.
&:state(ready)
> table
> tbody > tr
cursor: zoom-in
&:hover
background: $module-bg-color
magic-magnifier
--magic-magnifier-lens-width: 100px
--magic-magnifier-lens-height: 100px
--magic-magnifier-scale: 2.5
magic-magnifier-lens
z-index: 2 // Be above things by default, but not by much!

View file

@ -2,44 +2,36 @@ class AltStylesController < ApplicationController
before_action :support_staff_only, except: [:index] before_action :support_staff_only, except: [:index]
def index def index
@all_series_names = AltStyle.all_series_names @all_alt_styles = AltStyle.includes(:species, :color)
@all_color_names = AltStyle.all_supported_colors.map(&:human_name).sort
@all_species_names = AltStyle.all_supported_species.map(&:human_name).sort @all_colors = @all_alt_styles.map(&:color).uniq.sort_by(&:name)
@all_species = @all_alt_styles.map(&:species).uniq.sort_by(&:name)
@all_series_names = @all_alt_styles.map(&:series_name).uniq.sort
@all_color_names = @all_colors.map(&:human_name)
@all_species_names = @all_species.map(&:human_name)
@series_name = params[:series] @series_name = params[:series]
@color = find_color @color = find_color
@species = find_species @species = find_species
@alt_styles = AltStyle.includes(:color, :species, :swf_assets) @alt_styles = @all_alt_styles.includes(:swf_assets).
by_creation_date.order(:color_id, :species_id, :series_name).
paginate(page: params[:page], per_page: 30)
@alt_styles.where!(series_name: @series_name) if @series_name.present? @alt_styles.where!(series_name: @series_name) if @series_name.present?
@alt_styles.merge!(@color.alt_styles) if @color @alt_styles.merge!(@color.alt_styles) if @color
@alt_styles.merge!(@species.alt_styles) if @species @alt_styles.merge!(@species.alt_styles) if @species
# We're using the HTML5 image for our preview, so make sure we have all the
# manifests ready!
SwfAsset.preload_manifests @alt_styles.map(&:swf_assets).flatten
respond_to do |format| respond_to do |format|
format.html { format.html { render }
@alt_styles = @alt_styles.
by_creation_date.order(:color_id, :species_id, :series_name).
paginate(page: params[:page], per_page: 30)
# We're using the HTML5 image for our preview, so make sure we have all the
# manifests ready!
SwfAsset.preload_manifests @alt_styles.map(&:swf_assets).flatten
if support_staff?
@counts = {
total: AltStyle.count,
unlabeled: AltStyle.unlabeled.count,
}
@counts[:labeled] = @counts[:total] - @counts[:unlabeled]
@unlabeled_style = AltStyle.unlabeled.newest.first
end
render
}
format.json { format.json {
@alt_styles = @alt_styles.includes(swf_assets: [:zone]).by_name_grouped render json: @alt_styles.includes(swf_assets: [:zone]).as_json(
render json: @alt_styles.as_json( only: [:id, :species_id, :color_id, :body_id, :series_name,
only: [:id, :species_id, :color_id, :body_id, :thumbnail_url], :adjective_name, :thumbnail_url],
include: { include: {
swf_assets: { swf_assets: {
only: [:id, :body_id], only: [:id, :body_id],
@ -47,7 +39,7 @@ class AltStylesController < ApplicationController
methods: [:urls, :known_glitches], methods: [:urls, :known_glitches],
} }
}, },
methods: [:series_main_name, :adjective_name], methods: [:series_name, :adjective_name, :thumbnail_url],
) )
} }
end end
@ -71,8 +63,7 @@ class AltStylesController < ApplicationController
protected protected
def alt_style_params def alt_style_params
params.require(:alt_style). params.require(:alt_style).permit(:real_series_name, :thumbnail_url)
permit(:real_series_name, :real_full_name, :thumbnail_url)
end end
def find_color def find_color

View file

@ -4,7 +4,7 @@ require 'async/container'
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
protect_from_forgery protect_from_forgery
helper_method :current_user, :support_staff?, :user_signed_in? helper_method :current_user, :user_signed_in?
before_action :set_locale before_action :set_locale
@ -111,12 +111,10 @@ class ApplicationController < ActionController::Base
return_to || root_path return_to || root_path
end end
def support_staff?
current_user&.support_staff?
end
def support_staff_only def support_staff_only
raise AccessDenied, "Support staff only" unless support_staff? unless current_user&.support_staff?
raise AccessDenied, "Support staff only"
end
end end
end end

View file

@ -218,7 +218,7 @@ class ClosetHangersController < ApplicationController
def enforce_shadowban def enforce_shadowban
# If this user is shadowbanned, and this *doesn't* seem to be a request # If this user is shadowbanned, and this *doesn't* seem to be a request
# from that user, render the 404 page. # from that user, render the 404 page.
if !@user.visible_to?(current_user, request.remote_ip) if @user.shadowbanned? && !@user.likely_is?(current_user, request.remote_ip)
render file: "public/404.html", layout: false, status: :not_found render file: "public/404.html", layout: false, status: :not_found
end end
end end

View file

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

View file

@ -1,6 +1,5 @@
class ItemsController < ApplicationController class ItemsController < ApplicationController
before_action :set_query before_action :set_query
before_action :support_staff_only, except: [:index, :show, :sources]
rescue_from Item::Search::Error, :with => :search_error rescue_from Item::Search::Error, :with => :search_error
def index def index
@ -80,10 +79,7 @@ class ItemsController < ApplicationController
respond_to do |format| respond_to do |format|
format.html do format.html do
@trades = @item.visible_trades( @trades = @item.closet_hangers.trading.user_is_active.to_trades
user: current_user,
remote_ip: request.remote_ip
)
@contributors_with_counts = @item.contributors_with_counts @contributors_with_counts = @item.contributors_with_counts
@ -101,23 +97,14 @@ class ItemsController < ApplicationController
@preview_error = validate_preview @preview_error = validate_preview
@all_appearances = @item.appearances @all_appearances = @item.appearances
@appearances_by_occupied_zone_label = @appearances_by_occupied_zone = @item.appearances_by_occupied_zone.
@item.appearances_by_occupied_zone_label.sort_by { |l, a| l } sort_by { |z, a| z.label }
@selected_item_appearance = @preview_outfit.item_appearances.first @selected_item_appearance = @preview_outfit.item_appearances.first
@preview_pet_type_options = PetType.where(color: @preview_outfit.color). @preview_pet_type_options = PetType.where(color: @preview_outfit.color).
includes(:species).merge(Species.alphabetical) includes(:species).merge(Species.alphabetical)
end end
format.json do
render json: @item.as_json(
include_trade_counts: true,
include_nc_trade_value: true,
current_user: current_user,
remote_ip: request.remote_ip
)
end
format.gif do format.gif do
expires_in 1.month expires_in 1.month
redirect_to @item.thumbnail_url, allow_other_host: true redirect_to @item.thumbnail_url, allow_other_host: true
@ -125,21 +112,6 @@ class ItemsController < ApplicationController
end end
end end
def edit
@item = Item.find params[:id]
render layout: "application"
end
def update
@item = Item.find params[:id]
if @item.update(item_params)
flash[:notice] = "\"#{@item.name}\" successfully saved!"
redirect_to @item
else
render action: "edit", layout: "application", status: :bad_request
end
end
def sources def sources
# Load all the items, then group them by source. # Load all the items, then group them by source.
item_ids = params[:ids].split(",") item_ids = params[:ids].split(",")
@ -156,7 +128,7 @@ class ItemsController < ApplicationController
# For Dyeworks items whose base is currently in the NC Mall, preload their # For Dyeworks items whose base is currently in the NC Mall, preload their
# trade values. We'll use this to determine which ones are fully buyable rn # trade values. We'll use this to determine which ones are fully buyable rn
# (because our NC values guide tracks this data and we don't). # (because Owls tracks this data and we don't).
Item.preload_nc_trade_values(@items[:dyeworks]) Item.preload_nc_trade_values(@items[:dyeworks])
# Start loading the NC trade values for the non-Mall NC items. # Start loading the NC trade values for the non-Mall NC items.
@ -192,15 +164,6 @@ class ItemsController < ApplicationController
protected protected
def item_params
params.require(:item).permit(
:name, :thumbnail_url, :description, :modeling_status_hint,
:is_manually_nc, :explicitly_body_specific,
).tap do |p|
p[:modeling_status_hint] = nil if p[:modeling_status_hint] == ""
end
end
def assign_closeted!(items) def assign_closeted!(items)
current_user.assign_closeted_to_items!(items) if user_signed_in? current_user.assign_closeted_to_items!(items) if user_signed_in?
end end

View file

@ -5,10 +5,7 @@ class NeopetsConnectionsController < ApplicationController
if connection.save if connection.save
render json: connection render json: connection
else else
render json: { render json: {error: 'failure'}, status: :internal_server_error
errors: connection.errors,
full_error_messages: connection.errors.map(&:full_message)
}, status: :bad_request
end end
end end

View file

@ -13,26 +13,7 @@ class OutfitsController < ApplicationController
end end
def edit def edit
respond_to do |format| render "outfits/edit", layout: false
format.html { render "outfits/edit", layout: false }
format.png do
@outfit = build_outfit_from_wardrobe_params
if @outfit.valid?
renderer = OutfitImageRenderer.new(@outfit)
png_data = renderer.render
if png_data
send_data png_data, type: "image/png", disposition: "inline",
filename: "outfit.png"
expires_in 1.day, public: true
else
head :not_found
end
else
head :bad_request
end
end
end
end end
def index def index
@ -69,7 +50,10 @@ class OutfitsController < ApplicationController
@colors = Color.alphabetical @colors = Color.alphabetical
@species = Species.alphabetical @species = Species.alphabetical
newest_items = Item.newest.limit(18) newest_items = Item.newest.
select(:id, :name, :updated_at, :thumbnail_url, :rarity_index,
:is_manually_nc, :cached_compatible_body_ids)
.limit(18)
@newest_modeled_items, @newest_unmodeled_items = @newest_modeled_items, @newest_unmodeled_items =
newest_items.partition(&:predicted_fully_modeled?) newest_items.partition(&:predicted_fully_modeled?)
@ -136,40 +120,6 @@ class OutfitsController < ApplicationController
biology: [:species_id, :color_id, :pose, :pet_state_id]) biology: [:species_id, :color_id, :pose, :pet_state_id])
end end
def build_outfit_from_wardrobe_params
# Load items first
worn_item_ids = params[:objects] ? Array(params[:objects]).map(&:to_i) : []
closeted_item_ids = params[:closet] ? Array(params[:closet]).map(&:to_i) : []
worn_items = Item.where(id: worn_item_ids)
closeted_items = Item.where(id: closeted_item_ids)
# Build outfit with biology and items
outfit = Outfit.new(
worn_items: worn_items,
closeted_items: closeted_items,
)
# Set biology from species, color, and pose params
if params[:species] && params[:color] && params[:pose]
outfit.biology = {
species_id: params[:species],
color_id: params[:color],
pose: params[:pose]
}
elsif params[:state]
# Alternative: use pet_state_id directly
outfit.biology = { pet_state_id: params[:state] }
end
# Set alt style if provided
if params[:style]
outfit.alt_style_id = params[:style].to_i
end
outfit
end
def find_authorized_outfit def find_authorized_outfit
raise ActiveRecord::RecordNotFound unless user_signed_in? raise ActiveRecord::RecordNotFound unless user_signed_in?
@outfit = current_user.outfits.find(params[:id]) @outfit = current_user.outfits.find(params[:id])

View file

@ -1,7 +1,6 @@
class PetStatesController < ApplicationController class PetStatesController < ApplicationController
before_action :support_staff_only
before_action :find_pet_state before_action :find_pet_state
before_action :preload_assets before_action :support_staff_only
def edit def edit
end end
@ -9,7 +8,7 @@ class PetStatesController < ApplicationController
def update def update
if @pet_state.update(pet_state_params) if @pet_state.update(pet_state_params)
flash[:notice] = "Pet appearance \##{@pet_state.id} successfully saved!" flash[:notice] = "Pet appearance \##{@pet_state.id} successfully saved!"
redirect_to destination_after_save redirect_to @pet_type
else else
render action: :edit, status: :bad_request render action: :edit, status: :bad_request
end end
@ -18,39 +17,11 @@ class PetStatesController < ApplicationController
protected protected
def find_pet_state def find_pet_state
@pet_type = PetType.find_by_param!(params[:pet_type_name]) @pet_type = PetType.matching_name_param(params[:pet_type_name]).first!
@pet_state = @pet_type.pet_states.find(params[:id]) @pet_state = @pet_type.pet_states.find(params[:id])
@reference_pet_type = @pet_type.reference
end
def preload_assets
SwfAsset.preload_manifests @pet_state.swf_assets
end end
def pet_state_params def pet_state_params
params.require(:pet_state).permit(:pose, :glitched) params.require(:pet_state).permit(:pose, :glitched)
end end
def destination_after_save
if params[:next] == "unlabeled-appearance"
next_unlabeled_appearance_path
else
@pet_type
end
end
def next_unlabeled_appearance_path
unlabeled_appearance =
PetState.next_unlabeled_appearance(after_id: params[:after])
if unlabeled_appearance
edit_pet_type_pet_state_path(
unlabeled_appearance.pet_type,
unlabeled_appearance,
next: "unlabeled-appearance"
)
else
@pet_type
end
end
end end

View file

@ -35,16 +35,6 @@ class PetTypesController < ApplicationController
if @selected_species && @selected_color && @pet_types.size == 1 if @selected_species && @selected_color && @pet_types.size == 1
redirect_to @pet_types.first redirect_to @pet_types.first
end end
if support_staff?
@counts = {
total: PetState.count,
glitched: PetState.glitched.count,
needs_labeling: PetState.needs_labeling.count,
usable: PetState.usable.count,
}
@unlabeled_appearance = PetState.next_unlabeled_appearance
end
} }
format.json { format.json {
@ -80,7 +70,9 @@ class PetTypesController < ApplicationController
color_id: params[:color_id], color_id: params[:color_id],
) )
elsif params[:name] elsif params[:name]
PetType.find_by_param!(params[:name]) color_name, _, species_name = params[:name].rpartition("-")
raise ActiveRecord::RecordNotFound if species_name.blank?
PetType.matching_name(color_name, species_name).first!
else else
raise "expected params: species_id and color_id, or name" raise "expected params: species_id and color_id, or name"
end end

View file

@ -1,10 +1,12 @@
class PetsController < ApplicationController class PetsController < ApplicationController
rescue_from Neopets::CustomPets::PetNotFound, with: :pet_not_found rescue_from Neopets::CustomPets::PetNotFound, with: :pet_not_found
rescue_from Neopets::CustomPets::DownloadError, with: :pet_download_error rescue_from Neopets::CustomPets::DownloadError, with: :pet_download_error
rescue_from Pet::ModelingDisabled, with: :modeling_disabled
rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format
def load def load
# Uncomment this to temporarily disable modeling for most users.
# return modeling_disabled unless user_signed_in? && current_user.admin?
raise Neopets::CustomPets::PetNotFound unless params[:name] raise Neopets::CustomPets::PetNotFound unless params[:name]
@pet = Pet.load(params[:name]) @pet = Pet.load(params[:name])
points = contribute(current_user, @pet) points = contribute(current_user, @pet)

View file

@ -18,7 +18,7 @@ class SwfAssetsController < ApplicationController
# doing this can help make this header a *lot* shorter, which helps # doing this can help make this header a *lot* shorter, which helps
# our nginx reverse proxy (and probably some clients) handle it. (For # our nginx reverse proxy (and probably some clients) handle it. (For
# example, see asset `667993` for "Engulfed in Flames Effect".) # example, see asset `667993` for "Engulfed in Flames Effect".)
origins: ["https://images.neopets.com"], hosts: ["https://images.neopets.com"],
) )
} }
@ -45,23 +45,14 @@ class SwfAssetsController < ApplicationController
private private
def src_list(*urls, origins: []) def src_list(*urls, hosts: [])
clean_urls = urls. urls.
# Ignore any `nil`s that might arise # Ignore any `nil`s that might arise
filter(&:present?). filter(&:present?).
# Parse the URL.
map { |url| Addressable::URI.parse(url) }.
# Remove query strings from URLs (they're invalid in CSPs) # Remove query strings from URLs (they're invalid in CSPs)
each { |url| url.query = nil }. map { |url| url.sub(/\?.*\z/, "") }.
# For the given `origins`, remove all their specific URLs, because # For the given `hosts`, remove all their specific URLs, and just list
# we'll just include the entire origin anyway. # the host itself.
reject { |url| origins.include?(url.origin) }. reject { |url| hosts.any? { |h| url.start_with? h } } + hosts
# Normalize the URLs. (This fixes issues like when the canonical
# Neopets version of the URL contains plain unescaped spaces.)
each(&:normalize!).
# Convert the URLs back into strings.
map(&:to_s)
clean_urls + origins
end end
end end

View file

@ -1,6 +1,5 @@
class UsersController < ApplicationController class UsersController < ApplicationController
before_action :find_and_authorize_user!, only: [:edit, :update] before_action :find_and_authorize_user!, :only => [:update]
before_action :support_staff_only, only: [:edit]
def index # search, really def index # search, really
name = params[:name] name = params[:name]
@ -17,9 +16,6 @@ class UsersController < ApplicationController
@users = User.top_contributors.paginate :page => params[:page], :per_page => 20 @users = User.top_contributors.paginate :page => params[:page], :per_page => 20
end end
def edit
end
def update def update
@user.attributes = user_params @user.attributes = user_params
success = @user.save success = @user.save
@ -46,24 +42,17 @@ class UsersController < ApplicationController
protected protected
ALLOWED_ATTRS = [
:owned_closet_hangers_visibility,
:wanted_closet_hangers_visibility,
:contact_neopets_connection_id,
]
def user_params def user_params
if support_staff? params.require(:user).permit(:owned_closet_hangers_visibility,
params.require(:user).permit( :wanted_closet_hangers_visibility, :contact_neopets_connection_id)
*ALLOWED_ATTRS, :name, :shadowbanned, :support_staff
)
else
params.require(:user).permit(*ALLOWED_ATTRS)
end
end end
def find_and_authorize_user! def find_and_authorize_user!
@user = User.find(params[:id]) if current_user.id == params[:id].to_i
raise AccessDenied unless current_user == @user || support_staff? @user = current_user
else
raise AccessDenied
end
end end
end end

View file

@ -127,6 +127,10 @@ module ApplicationHelper
!@hide_home_link !@hide_home_link
end end
def support_staff?
user_signed_in? && current_user.support_staff?
end
def impress_2020_meta_tags def impress_2020_meta_tags
origin = Rails.configuration.impress_2020_origin origin = Rails.configuration.impress_2020_origin
support_secret = Rails.application.credentials.dig( support_secret = Rails.application.credentials.dig(
@ -213,10 +217,6 @@ module ApplicationHelper
@hide_title_header = true @hide_title_header = true
end end
def hide_after(last_day, &block)
yield if Date.today <= last_day
end
def use_responsive_design def use_responsive_design
@use_responsive_design = true @use_responsive_design = true
add_body_class "use-responsive-design" add_body_class "use-responsive-design"

View file

@ -1,28 +1,22 @@
module ItemTradesHelper module ItemTradesHelper
def vague_trade_timestamp(trade) def vague_trade_timestamp(last_trade_activity_at)
return nil if trade.nil? if last_trade_activity_at >= 1.week.ago
if trade.last_activity_at >= 1.week.ago
translate "item_trades.index.table.last_active.this_week" translate "item_trades.index.table.last_active.this_week"
else else
trade.last_activity_at.to_date.to_fs(:month_and_year) last_trade_activity_at.strftime("%b %Y")
end end
end end
def same_vague_trade_timestamp?(trade1, trade2)
vague_trade_timestamp(trade1) == vague_trade_timestamp(trade2)
end
def sorted_vaguely_by_trade_activity(trades) def sorted_vaguely_by_trade_activity(trades)
# First, sort the list in ascending order. # First, sort the list in ascending order.
trades_ascending = trades.sort_by do |trade| trades_ascending = trades.sort_by do |trade|
if trade.last_activity_at >= 1.week.ago if trade.user.last_trade_activity_at >= 1.week.ago
# Sort recent trades in a random order, but still collectively as the # Sort recent trades in a random order, but still collectively as the
# most recent. (This discourages spamming updates to game the system!) # most recent. (This discourages spamming updates to game the system!)
[1, rand] [1, rand]
else else
# Sort older trades by last trade activity. # Sort older trades by last trade activity.
[0, trade.last_activity_at] [0, trade.user.last_trade_activity_at]
end end
end end

View file

@ -141,13 +141,6 @@ module ItemsHelper
def auction_genie_url_for(item) def auction_genie_url_for(item)
AUCTION_GENIE_URL_TEMPLATE.expand(auctiongenie: item.name).to_s AUCTION_GENIE_URL_TEMPLATE.expand(auctiongenie: item.name).to_s
end end
LEBRON_URL_TEMPLATE = Addressable::Template.new(
"https://stylisher.club/search/{name}"
)
def lebron_url_for(item)
LEBRON_URL_TEMPLATE.expand(name: item.name).to_s
end
def format_contribution_count(count) def format_contribution_count(count)
" (&times;#{count})".html_safe if count > 1 " (&times;#{count})".html_safe if count > 1
@ -158,7 +151,7 @@ module ItemsHelper
end end
def nc_trade_value_updated_at_text(nc_trade_value) def nc_trade_value_updated_at_text(nc_trade_value)
return "NC trade value" if nc_trade_value.updated_at.nil? return nil if nc_trade_value.updated_at.nil?
# Render both "[X] [days] ago", and also the exact date, only including the # Render both "[X] [days] ago", and also the exact date, only including the
# year if it's not this same year. # year if it's not this same year.
@ -167,7 +160,7 @@ module ItemsHelper
nc_trade_value.updated_at.strftime("%b %-d") : nc_trade_value.updated_at.strftime("%b %-d") :
nc_trade_value.updated_at.strftime("%b %-d, %Y") nc_trade_value.updated_at.strftime("%b %-d, %Y")
"NC trade value—Last updated: #{date_str} (#{time_ago_str} ago)" "Last updated: #{date_str} (#{time_ago_str} ago)"
end end
NC_TRADE_VALUE_ESTIMATE_PATTERN = %r{ NC_TRADE_VALUE_ESTIMATE_PATTERN = %r{
@ -191,7 +184,7 @@ module ItemsHelper
# nicely for our use case. # nicely for our use case.
def nc_trade_value_estimate_text(nc_trade_value) def nc_trade_value_estimate_text(nc_trade_value)
match = nc_trade_value.value_text.match(NC_TRADE_VALUE_ESTIMATE_PATTERN) match = nc_trade_value.value_text.match(NC_TRADE_VALUE_ESTIMATE_PATTERN)
return nc_trade_value.value_text if match.nil? return nc_trade_value if match.nil?
match => {single:, low:, high:} match => {single:, low:, high:}
if single.present? if single.present?
@ -199,7 +192,7 @@ module ItemsHelper
elsif low.present? && high.present? elsif low.present? && high.present?
"#{low}#{high} capsules" "#{low}#{high} capsules"
else else
nc_trade_value.value_text nc_trade_value
end end
end end

View file

@ -1,4 +1,9 @@
module OutfitsHelper module OutfitsHelper
LAST_DAY_OF_ANNOUNCEMENT = Date.parse("2024-10-21")
def show_announcement?
Date.today <= LAST_DAY_OF_ANNOUNCEMENT
end
def destination_tag(value) def destination_tag(value)
hidden_field_tag 'destination', value, :id => nil hidden_field_tag 'destination', value, :id => nil
end end
@ -65,27 +70,11 @@ module OutfitsHelper
text_field_tag 'name', nil, options text_field_tag 'name', nil, options
end end
def outfit_viewer(...) def outfit_viewer(outfit=nil, pet_state: nil, **html_options)
render partial: "outfit_viewer",
locals: parse_outfit_viewer_options(...)
end
def support_outfit_viewer(...)
render partial: "support_outfit_viewer",
locals: parse_outfit_viewer_options(...)
end
private
def parse_outfit_viewer_options(
outfit=nil, pet_state: nil, preferred_image_format: :png, **html_options
)
outfit = Outfit.new(pet_state:) if outfit.nil? && pet_state.present? outfit = Outfit.new(pet_state:) if outfit.nil? && pet_state.present?
raise "outfit_viewer must have outfit or pet state" if outfit.nil?
if outfit.nil? render partial: "outfit_viewer", locals: {outfit:, html_options:}
raise ArgumentError, "outfit viewer must have outfit or pet state"
end
{outfit:, preferred_image_format:, html_options:}
end end
end end

View file

@ -1,64 +0,0 @@
module SupportFormHelper
class SupportFormBuilder < ActionView::Helpers::FormBuilder
attr_reader :template
delegate :capture, :check_box_tag, :concat, :content_tag,
:hidden_field_tag, :params, :render,
to: :template, private: true
def errors
render partial: "application/support_form/errors", locals: {form: self}
end
def fields(&block)
content_tag(:ul, class: "fields", &block)
end
def field(**options, &block)
content_tag(:li, **options, &block)
end
def radio_fieldset(legend, **options, &block)
render partial: "application/support_form/radio_fieldset",
locals: {form: self, legend:, options:, content: capture(&block)}
end
def radio_field(**options, &block)
content_tag(:li) do
content_tag(:label, **options, &block)
end
end
def radio_grid_fieldset(*args, &block)
radio_fieldset(*args, "data-type": "radio-grid", &block)
end
def thumbnail_input(method)
render partial: "application/support_form/thumbnail_input",
locals: {form: self, method:}
end
def actions(&block)
content_tag(:section, class: "actions", &block)
end
def go_to_next_field(after: nil, **options, &block)
content_tag(:label, class: "go-to-next", **options) do
concat hidden_field_tag(:after, after) if after
yield
end
end
def go_to_next_check_box(value)
check_box_tag "next", value, checked: params[:next] == value
end
end
def support_form_with(**options, &block)
form_with(
builder: SupportFormBuilder,
**options,
class: ["support-form", options[:class]],
&block
)
end
end

View file

@ -1,4 +1,6 @@
import React from "react"; import React from "react";
import * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing";
import { import {
ChakraProvider, ChakraProvider,
Box, Box,
@ -41,6 +43,8 @@ const globalStyles = theme.styles.global;
theme.styles.global = {}; theme.styles.global = {};
export default function AppProvider({ children }) { export default function AppProvider({ children }) {
React.useEffect(() => setupLogging(), []);
return ( return (
<BrowserRouter> <BrowserRouter>
<QueryClientProvider client={reactQueryClient}> <QueryClientProvider client={reactQueryClient}>
@ -54,6 +58,47 @@ export default function AppProvider({ children }) {
); );
} }
function setupLogging() {
Sentry.init({
dsn: "https://c55875c3b0904264a1a99e5b741a221e@o506079.ingest.sentry.io/5595379",
autoSessionTracking: true,
integrations: [
new Integrations.BrowserTracing({
beforeNavigate: (context) => ({
...context,
// Assume any path segment starting with a digit is an ID, and replace
// it with `:id`. This will help group related routes in Sentry stats.
// NOTE: I'm a bit uncertain about the timing on this for tracking
// client-side navs... but we now only track first-time
// pageloads, and it definitely works correctly for them!
name: window.location.pathname.replaceAll(/\/[0-9][^/]*/g, "/:id"),
}),
// We have a _lot_ of location changes that don't actually signify useful
// navigations, like in the wardrobe page. It could be useful to trace
// them with better filtering someday, but frankly we don't use the perf
// features besides Web Vitals right now, and those only get tracked on
// first-time pageloads, anyway. So, don't track client-side navs!
startTransactionOnLocationChange: false,
}),
],
denyUrls: [
// Don't log errors that were probably triggered by extensions and not by
// our own app. (Apparently Sentry's setting to ignore browser extension
// errors doesn't do this anywhere near as consistently as I'd expect?)
//
// Adapted from https://gist.github.com/impressiver/5092952, as linked in
// https://docs.sentry.io/platforms/javascript/configuration/filtering/.
/^chrome-extension:\/\//,
/^moz-extension:\/\//,
],
// Since we're only tracking first-page loads and not navigations, 100%
// sampling isn't actually so much! Tune down if it becomes a problem, tho.
tracesSampleRate: 1.0,
});
}
/** /**
* ScopedCSSReset applies a copy of Chakra UI's CSS reset, but only to its * ScopedCSSReset applies a copy of Chakra UI's CSS reset, but only to its
* children (or, well, any element with the chakra-css-reset class). It also * children (or, well, any element with the chakra-css-reset class). It also

View file

@ -1,10 +1,11 @@
import React from "react"; import React from "react";
import { Box, Flex, useBreakpointValue } from "@chakra-ui/react"; import { Box, Flex, useBreakpointValue } from "@chakra-ui/react";
import * as Sentry from "@sentry/react";
import ItemsPanel from "./ItemsPanel"; import ItemsPanel from "./ItemsPanel";
import SearchToolbar, { searchQueryIsEmpty } from "./SearchToolbar"; import SearchToolbar, { searchQueryIsEmpty } from "./SearchToolbar";
import SearchPanel from "./SearchPanel"; import SearchPanel from "./SearchPanel";
import { ErrorBoundary, TestErrorSender, useLocalStorage } from "../util"; import { MajorErrorMessage, TestErrorSender, useLocalStorage } from "../util";
/** /**
* ItemsAndSearchPanels manages the shared layout and state for: * ItemsAndSearchPanels manages the shared layout and state for:
@ -39,7 +40,7 @@ function ItemsAndSearchPanels({
const isShowingSearchFooter = canUseSearchFooter && hasRoomForSearchFooter; const isShowingSearchFooter = canUseSearchFooter && hasRoomForSearchFooter;
return ( return (
<ErrorBoundary> <Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender /> <TestErrorSender />
<Flex direction="column" height="100%"> <Flex direction="column" height="100%">
{isShowingSearchFooter && <Box height="2" />} {isShowingSearchFooter && <Box height="2" />}
@ -84,7 +85,7 @@ function ItemsAndSearchPanels({
</Box> </Box>
)} )}
</Flex> </Flex>
</ErrorBoundary> </Sentry.ErrorBoundary>
); );
} }

View file

@ -233,7 +233,7 @@ function OutfitControls({
/> />
</DarkMode> </DarkMode>
</Box> </Box>
<Flex flex="0 1 auto" align="center" pl="2"> <Flex flex="0 0 auto" align="center" pl="2">
<PosePicker <PosePicker
speciesId={outfitState.speciesId} speciesId={outfitState.speciesId}
colorId={outfitState.colorId} colorId={outfitState.colorId}

View file

@ -283,10 +283,7 @@ const PosePickerButton = React.forwardRef(
const theme = useTheme(); const theme = useTheme();
const icon = altStyle != null ? twemojiSunglasses : getIcon(pose); const icon = altStyle != null ? twemojiSunglasses : getIcon(pose);
const label = const label = altStyle != null ? altStyle.seriesName : getLabel(pose);
altStyle != null
? altStyle.seriesMainName.split(/\s+/)[0]
: getLabel(pose);
return ( return (
<ClassNames> <ClassNames>
@ -339,9 +336,9 @@ const PosePickerButton = React.forwardRef(
ref={ref} ref={ref}
> >
<EmojiImage src={icon} alt="Style" /> <EmojiImage src={icon} alt="Style" />
<Box overflow="hidden" textOverflow="ellipsis" marginX=".5em"> <Box width=".5em" />
{label} {label}
</Box> <Box width=".5em" />
<ChevronDownIcon /> <ChevronDownIcon />
</Button> </Button>
)} )}
@ -726,13 +723,6 @@ function StyleOption({ altStyle, checked, onChange, inputRef }) {
checked={checked} checked={checked}
onChange={(e) => onChange(altStyle.id)} onChange={(e) => onChange(altStyle.id)}
ref={inputRef} ref={inputRef}
// HACK: Without this, the page extends super long. I think this is
// because the VisuallyHidden just uses `position: absolute`,
// which makes it float invisibly *beyond* the scrolling
// container it's in, extending the page? But if we put it at
// the top-left corner instead, it doesn't.
left="0"
top="0"
/> />
<Flex <Flex
alignItems="center" alignItems="center"

View file

@ -1,7 +1,8 @@
import React from "react"; import React from "react";
import * as Sentry from "@sentry/react";
import { Box, Flex } from "@chakra-ui/react"; import { Box, Flex } from "@chakra-ui/react";
import SearchToolbar from "./SearchToolbar"; import SearchToolbar from "./SearchToolbar";
import { ErrorBoundary, TestErrorSender, useLocalStorage } from "../util"; import { MajorErrorMessage, TestErrorSender, useLocalStorage } from "../util";
import PaginationToolbar from "../components/PaginationToolbar"; import PaginationToolbar from "../components/PaginationToolbar";
import { useSearchResults } from "./useSearchResults"; import { useSearchResults } from "./useSearchResults";
@ -33,7 +34,7 @@ function SearchFooter({ searchQuery, onChangeSearchQuery, outfitState }) {
} }
return ( return (
<ErrorBoundary> <Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender /> <TestErrorSender />
<Box> <Box>
<Box paddingX="4" paddingY="4"> <Box paddingX="4" paddingY="4">
@ -72,7 +73,7 @@ function SearchFooter({ searchQuery, onChangeSearchQuery, outfitState }) {
</Box> </Box>
</Box> </Box>
</Box> </Box>
</ErrorBoundary> </Sentry.ErrorBoundary>
); );
} }

View file

@ -1,18 +1,10 @@
import React from "react"; import React from "react";
import { import { Box, Text, useColorModeValue, VisuallyHidden } from "@chakra-ui/react";
Alert,
AlertIcon,
Box,
Text,
useColorModeValue,
VisuallyHidden,
} from "@chakra-ui/react";
import Item, { ItemListContainer, ItemListSkeleton } from "./Item"; import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
import PaginationToolbar from "../components/PaginationToolbar"; import PaginationToolbar from "../components/PaginationToolbar";
import { useSearchResults } from "./useSearchResults"; import { useSearchResults } from "./useSearchResults";
import { MajorErrorMessage } from "../util"; import { MajorErrorMessage } from "../util";
import { useAltStylesForSpecies } from "../loaders/alt-styles";
export const SEARCH_PER_PAGE = 30; export const SEARCH_PER_PAGE = 30;
@ -169,7 +161,6 @@ function SearchResults({
size="sm" size="sm"
/> />
</Box> </Box>
<SearchNCStylesHint query={query} outfitState={outfitState} />
<ItemListContainer paddingX="4" paddingBottom="2"> <ItemListContainer paddingX="4" paddingBottom="2">
{items.map((item, index) => ( {items.map((item, index) => (
<SearchResultItem <SearchResultItem
@ -271,55 +262,6 @@ function SearchResultItem({
); );
} }
function SearchNCStylesHint({ query, outfitState }) {
const { data: altStyles } = useAltStylesForSpecies(outfitState.speciesId);
const message = getSearchNCStylesMessage(query, altStyles);
if (!message) {
return null;
}
return (
<Box paddingX="4" paddingY="2">
<Alert status="info" variant="left-accent" fontSize="sm" color="blue.900">
<AlertIcon />
{message}
</Alert>
</Box>
);
}
function getSearchNCStylesMessage(query, altStyles) {
const seriesMainNames = [
...new Set((altStyles ?? []).map((as) => as.seriesMainName)),
];
const queryWords = query.value.toLowerCase().split(/\s+/);
if (queryWords.includes("token")) {
return (
<>
If you're looking for NC Styles, check the emotion picker below the pet
preview!
</>
);
}
// NOTE: This won't work on multi-word series main names, of which there
// are currently none. (Some *series names* like Prismatics are
// multi-word, but their *main* name is not.)
const seriesWord = seriesMainNames.find((n) =>
queryWords.includes(n.toLowerCase()),
);
if (seriesWord != null) {
return (
<>
If you're looking for {seriesWord} NC Styles, check the emotion picker
below the pet preview!
</>
);
}
}
/** /**
* serializeQuery stably converts a search query object to a string, for easier * serializeQuery stably converts a search query object to a string, for easier
* JS comparison. * JS comparison.

View file

@ -2,10 +2,11 @@ import React from "react";
import { Box, Center, DarkMode } from "@chakra-ui/react"; import { Box, Center, DarkMode } from "@chakra-ui/react";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import * as Sentry from "@sentry/react";
import OutfitThumbnail from "../components/OutfitThumbnail"; import OutfitThumbnail from "../components/OutfitThumbnail";
import { useOutfitPreview } from "../components/OutfitPreview"; import { useOutfitPreview } from "../components/OutfitPreview";
import { loadable, ErrorBoundary, TestErrorSender } from "../util"; import { loadable, MajorErrorMessage, TestErrorSender } from "../util";
const OutfitControls = loadable(() => import("./OutfitControls")); const OutfitControls = loadable(() => import("./OutfitControls"));
@ -32,7 +33,7 @@ function WardrobePreviewAndControls({
}); });
return ( return (
<ErrorBoundary> <Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender /> <TestErrorSender />
<Center position="absolute" top="0" bottom="0" left="0" right="0"> <Center position="absolute" top="0" bottom="0" left="0" right="0">
<DarkMode>{preview}</DarkMode> <DarkMode>{preview}</DarkMode>
@ -45,7 +46,7 @@ function WardrobePreviewAndControls({
appearance={appearance} appearance={appearance}
/> />
</Box> </Box>
</ErrorBoundary> </Sentry.ErrorBoundary>
); );
} }

View file

@ -49,7 +49,7 @@ function normalizeAltStyle(altStyleData) {
speciesId: String(altStyleData.species_id), speciesId: String(altStyleData.species_id),
colorId: String(altStyleData.color_id), colorId: String(altStyleData.color_id),
bodyId: String(altStyleData.body_id), bodyId: String(altStyleData.body_id),
seriesMainName: altStyleData.series_main_name, seriesName: altStyleData.series_name,
adjectiveName: altStyleData.adjective_name, adjectiveName: altStyleData.adjective_name,
thumbnailUrl: altStyleData.thumbnail_url, thumbnailUrl: altStyleData.thumbnail_url,

View file

@ -8,6 +8,7 @@ import {
useColorModeValue, useColorModeValue,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import loadableLibrary from "@loadable/component"; import loadableLibrary from "@loadable/component";
import * as Sentry from "@sentry/react";
import { WarningIcon } from "@chakra-ui/icons"; import { WarningIcon } from "@chakra-ui/icons";
import { buildImpress2020Url } from "./impress-2020-config"; import { buildImpress2020Url } from "./impress-2020-config";
@ -413,17 +414,14 @@ export function loadable(load, options) {
} }
/** /**
* logAndCapture will print an error to the console. * logAndCapture will print an error to the console, and send it to Sentry.
*
* NOTE: Previously, this would log to Sentry, but we actually just don't log
* JS errors anymore, because we haven't done in-depth JS debugging in a
* while.
* *
* This is useful when there's a graceful recovery path, but it's still a * This is useful when there's a graceful recovery path, but it's still a
* genuinely unexpected error worth logging. * genuinely unexpected error worth logging.
*/ */
export function logAndCapture(e) { export function logAndCapture(e) {
console.error(e); console.error(e);
Sentry.captureException(e);
} }
export function getGraphQLErrorMessage(error) { export function getGraphQLErrorMessage(error) {
@ -477,8 +475,8 @@ export function MajorErrorMessage({ error = null, variant = "unexpected" }) {
<Box gridArea="description" marginBottom="2"> <Box gridArea="description" marginBottom="2">
{variant === "unexpected" && ( {variant === "unexpected" && (
<> <>
There was an error displaying this page. If it keeps happening, There was an error displaying this page. I'll get info about it
you can tell me more at{" "} automatically, but you can tell me more at{" "}
<Link href="mailto:matchu@openneo.net" color="green.400"> <Link href="mailto:matchu@openneo.net" color="green.400">
matchu@openneo.net matchu@openneo.net
</Link> </Link>
@ -525,29 +523,10 @@ export function MajorErrorMessage({ error = null, variant = "unexpected" }) {
export function TestErrorSender() { export function TestErrorSender() {
React.useEffect(() => { React.useEffect(() => {
if (window.location.href.includes("send-test-error")) { if (window.location.href.includes("send-test-error-for-sentry")) {
throw new Error("Test error for debugging <ErrorBoundary>s"); throw new Error("Test error for Sentry");
} }
}); });
return null; return null;
} }
export class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error) {
return { error };
}
render() {
if (this.state.error != null) {
return <MajorErrorMessage error={this.state.error} />;
}
return this.props.children;
}
}

View file

@ -9,46 +9,18 @@ class AltStyle < ApplicationRecord
has_many :contributions, as: :contributed, inverse_of: :contributed has_many :contributions, as: :contributed, inverse_of: :contributed
validates :body_id, presence: true validates :body_id, presence: true
validates :full_name, presence: true, allow_nil: true
validates :series_name, presence: true, allow_nil: true validates :series_name, presence: true, allow_nil: true
validates :thumbnail_url, presence: true validates :thumbnail_url, presence: true
before_validation :infer_thumbnail_url, unless: :thumbnail_url? before_validation :infer_thumbnail_url, unless: :thumbnail_url?
fallback_for(:full_name) { "#{series_name} #{pet_name}" }
fallback_for(:series_name) { AltStyle.placeholder_name }
scope :matching_name, ->(series_name, color_name, species_name) { scope :matching_name, ->(series_name, color_name, species_name) {
color = Color.find_by_name!(color_name) color = Color.find_by_name!(color_name)
species = Species.find_by_name!(species_name) species = Species.find_by_name!(species_name)
where(series_name:, color_id: color.id, species_id: species.id) where(series_name:, color_id: color.id, species_id: species.id)
} }
scope :by_creation_date, -> { scope :by_creation_date, -> {
# HACK: Setting up named time zones in MySQL takes effort, so we assume order("DATE(created_at) DESC")
# it's not Daylight Savings. This will produce slightly incorrect
# sorting when it *is* Daylight Savings, and records happen to be
# created around midnight.
order(Arel.sql("DATE(CONVERT_TZ(created_at, '+00:00', '-08:00')) DESC"))
}
scope :by_series_main_name, -> {
# The main part of the series name, like "Nostalgic".
# If there's no colon, uses the whole string.
order(Arel.sql("SUBSTRING_INDEX(series_name, ': ', -1)"))
}
scope :by_series_variant_name, -> {
# The variant part of the series name, like "Prismatic Cyan".
# If there's no colon, uses an empty string.
order(Arel.sql("SUBSTRING(series_name, 1, LOCATE(': ', series_name) - 1)"))
}
scope :by_color_name, -> {
joins(:color).order(Color.arel_table[:name])
}
scope :by_name_grouped, -> {
# Sort by the color name, then the main part of the series name, then the
# variant part of the series name. This way, all the, say, Christmas colors
# and their Prismatic variants will be together, including both Festive and
# Nostalgic cases.
by_color_name.by_series_main_name.by_series_variant_name
} }
scope :unlabeled, -> { where(series_name: nil) } scope :unlabeled, -> { where(series_name: nil) }
scope :newest, -> { order(created_at: :desc) } scope :newest, -> { order(created_at: :desc) }
@ -60,23 +32,41 @@ class AltStyle < ApplicationRecord
alias_method :name, :pet_name alias_method :name, :pet_name
def series_main_name # If the series_name hasn't yet been set manually by support staff, show the
series_name.split(': ').last # string "<New?>" instead. But it won't be searchable by that string—that is,
# `fits:<New?>-faerie-draik` intentionally will not work, and the canonical
# filter name will be `fits:alt-style-IDNUMBER`, instead.
def series_name
real_series_name || "<New?>"
end end
def series_variant_name def real_series_name=(new_series_name)
series_name.split(': ').first self[:series_name] = new_series_name
end
def real_series_name
self[:series_name]
end
# You can use this to check whether `series_name` is returning the actual
# value or its placeholder value.
def real_series_name?
real_series_name.present?
end end
# Returns the full name, with the species removed from the end (if present).
def adjective_name def adjective_name
full_name.sub(/\s+#{Regexp.escape(species.name)}\Z/i, "") "#{series_name} #{color.human_name}"
end
def full_name
"#{series_name} #{name}"
end end
EMPTY_IMAGE_URL = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
def preview_image_url def preview_image_url
# Use the image URL for the first asset. Or, fall back to an empty image. swf_asset = swf_assets.first
swf_assets.first&.image_url || EMPTY_IMAGE_URL return nil if swf_asset.nil?
swf_asset.image_url
end end
# Given a list of items, return how they look on this alt style. # Given a list of items, return how they look on this alt style.
@ -84,6 +74,15 @@ class AltStyle < ApplicationRecord
Item.appearances_for(items, self, ...) Item.appearances_for(items, self, ...)
end end
def biology=(biology)
# TODO: This is very similar to what `PetState` does, but like… much much
# more compact? Idk if I'm missing something, or if I was just that much
# more clueless back when I wrote it, lol 😅
self.swf_assets = biology.values.map do |asset_data|
SwfAsset.from_biology_data(self.body_id, asset_data)
end
end
# At time of writing, most batches of Alt Styles thumbnails used a simple # At time of writing, most batches of Alt Styles thumbnails used a simple
# pattern for the item thumbnail URL, but that's not always the case anymore. # pattern for the item thumbnail URL, but that's not always the case anymore.
# For now, let's keep using this format as the default value when creating a # For now, let's keep using this format as the default value when creating a
@ -104,28 +103,6 @@ class AltStyle < ApplicationRecord
end end
end end
def real_thumbnail_url?
thumbnail_url != DEFAULT_THUMBNAIL_URL
end
def self.placeholder_name
"<New?>"
end
def self.all_series_names
distinct.where.not(series_name: nil).
by_series_main_name.by_series_variant_name.
pluck(:series_name)
end
def self.all_supported_colors
Color.find(distinct.pluck(:color_id))
end
def self.all_supported_species
Species.find(distinct.pluck(:species_id))
end
# For convenience in the console! # For convenience in the console!
def self.find_by_name(color_name, species_name) def self.find_by_name(color_name, species_name)
color = Color.find_by_name(color_name) color = Color.find_by_name(color_name)

View file

@ -1,35 +1,3 @@
class ApplicationRecord < ActiveRecord::Base class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true self.abstract_class = true
# When the given attribute's value is nil, return the given block's value
# instead. This is useful for fields where there's a sensible derived value
# to use as the default for display purposes, but it's distinctly *not* the
# true value, and should be recognizable as such.
#
# This also creates methods `real_<attr>`, `real_<attr>=`, and `real_<attr>?`,
# to work with the actual attribute when necessary.
#
# It also creates `fallback_<attr>`, to find what the fallback value *would*
# be if the attribute's value were nil.
def self.fallback_for(attribute_name, &block)
define_method attribute_name do
read_attribute(attribute_name) || instance_eval(&block)
end
define_method "real_#{attribute_name}" do
read_attribute(attribute_name)
end
define_method "real_#{attribute_name}?" do
read_attribute(attribute_name).present?
end
define_method "real_#{attribute_name}=" do |new_value|
write_attribute(attribute_name, new_value)
end
define_method "fallback_#{attribute_name}" do
instance_eval(&block)
end
end
end end

View file

@ -156,7 +156,7 @@ class ClosetHanger < ApplicationRecord
# #
# We don't preload anything here - if you want user names or list names, you # We don't preload anything here - if you want user names or list names, you
# should `includes` them in the hanger scope first, to avoid extra queries! # should `includes` them in the hanger scope first, to avoid extra queries!
def self.to_trades(current_user, remote_ip) def self.to_trades
# Let's ensure that the `trading` filter is applied, to avoid data leaks. # Let's ensure that the `trading` filter is applied, to avoid data leaks.
# (I still recommend doing it at the call site too for clarity, though!) # (I still recommend doing it at the call site too for clarity, though!)
all_trading_hangers = trading.to_a all_trading_hangers = trading.to_a
@ -164,20 +164,17 @@ class ClosetHanger < ApplicationRecord
owned_hangers = all_trading_hangers.filter(&:owned?) owned_hangers = all_trading_hangers.filter(&:owned?)
wanted_hangers = all_trading_hangers.filter(&:wanted?) wanted_hangers = all_trading_hangers.filter(&:wanted?)
# Group first into offering vs seeking, then by user. Only include trades # Group first into offering vs seeking, then by user.
# visible to the current user.
offering, seeking = [owned_hangers, wanted_hangers].map do |hangers| offering, seeking = [owned_hangers, wanted_hangers].map do |hangers|
hangers.group_by(&:user_id). hangers.group_by(&:user_id).map do |user_id, user_hangers|
map { |user_id, user_hangers| Trade.new(user_id, user_hangers) }. Trade.new(user_id, user_hangers)
filter { |trade| trade.visible_to?(current_user, remote_ip) } end
end end
{offering: offering, seeking: seeking} {offering: offering, seeking: seeking}
end end
Trade = Struct.new('Trade', :user_id, :hangers) do Trade = Struct.new('Trade', :user_id, :hangers) do
delegate :visible_to?, to: :user
def user def user
# Take advantage of `includes(:user)` on the hangers, if applied. # Take advantage of `includes(:user)` on the hangers, if applied.
hangers.first.user hangers.first.user
@ -186,10 +183,6 @@ class ClosetHanger < ApplicationRecord
def lists def lists
hangers.map(&:list).filter(&:present?) hangers.map(&:list).filter(&:present?)
end end
def last_activity_at
user.last_trade_activity_at
end
end end
protected protected

View file

@ -21,10 +21,6 @@ class Color < ApplicationRecord
end end
end end
def to_param
name? ? human_name : id.to_s
end
def example_pet_type(preferred_species: nil) def example_pet_type(preferred_species: nil)
preferred_species ||= Species.first preferred_species ||= Species.first
pet_types.order([Arel.sql("species_id = ? DESC"), preferred_species.id], pet_types.order([Arel.sql("species_id = ? DESC"), preferred_species.id],
@ -40,8 +36,4 @@ class Color < ApplicationRecord
nil nil
end end
end end
def self.param_to_id(param)
param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id
end
end end

View file

@ -1,3 +1,6 @@
require "async"
require "async/barrier"
class Item < ApplicationRecord class Item < ApplicationRecord
include PrettyParam include PrettyParam
include Item::Dyeworks include Item::Dyeworks
@ -20,21 +23,13 @@ class Item < ApplicationRecord
has_many :dyeworks_variants, class_name: "Item", has_many :dyeworks_variants, class_name: "Item",
inverse_of: :dyeworks_base_item inverse_of: :dyeworks_base_item
# We require a name field. A number of other fields must be *specified*: they validates_presence_of :name, :description, :thumbnail_url, :rarity, :price,
# can't be nil, to help ensure we aren't forgetting any fields when importing :zones_restrict
# items. But sometimes they happen to be blank (e.g. when TNT leaves an item
# description empty, oops), in which case we want to accept that reality!
validates_presence_of :name
validates :description, :thumbnail_url, :rarity, :price, :zones_restrict,
exclusion: {in: [nil], message: "must be specified"}
after_save :update_cached_fields,
if: :modeling_status_hint_previously_changed?
attr_writer :current_body_id, :owned, :wanted attr_writer :current_body_id, :owned, :wanted
NCRarities = [0, 500] NCRarities = [0, 500]
PAINTBRUSH_SET_DESCRIPTION = 'This item is part of a deluxe paint brush set' PAINTBRUSH_SET_DESCRIPTION = 'This item is part of a deluxe paint brush set!'
scope :newest, -> { scope :newest, -> {
order(arel_table[:created_at].desc) if arel_table[:created_at] order(arel_table[:created_at].desc) if arel_table[:created_at]
@ -70,12 +65,6 @@ class Item < ApplicationRecord
where('description NOT LIKE ?', where('description NOT LIKE ?',
'%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%') '%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%')
} }
scope :is_modeled, -> {
where(cached_predicted_fully_modeled: true)
}
scope :is_not_modeled, -> {
where(cached_predicted_fully_modeled: false)
}
scope :occupies, ->(zone_label) { scope :occupies, ->(zone_label) {
Zone.matching_label(zone_label). Zone.matching_label(zone_label).
map { |z| occupies_zone_id(z.id) }.reduce(none, &:or) map { |z| occupies_zone_id(z.id) }.reduce(none, &:or)
@ -118,11 +107,12 @@ class Item < ApplicationRecord
return @nc_trade_value if @nc_trade_value_loaded return @nc_trade_value if @nc_trade_value_loaded
@nc_trade_value = begin @nc_trade_value = begin
LebronNCValues.find_by_name(name) Rails.logger.debug "Item #{id} (#{name}) <lookup>"
rescue LebronNCValues::NotFound => error OwlsValueGuide.find_by_name(name)
rescue OwlsValueGuide::NotFound => error
Rails.logger.debug("No NC trade value listed for #{name} (#{id})") Rails.logger.debug("No NC trade value listed for #{name} (#{id})")
nil nil
rescue LebronNCValues::NetworkError => error rescue OwlsValueGuide::NetworkError => error
Rails.logger.error("Couldn't load nc_trade_value: #{error.full_message}") Rails.logger.error("Couldn't load nc_trade_value: #{error.full_message}")
nil nil
end end
@ -162,7 +152,7 @@ class Item < ApplicationRecord
end end
def pb? def pb?
I18n.with_locale(:en) { self.description.include?(PAINTBRUSH_SET_DESCRIPTION) } I18n.with_locale(:en) { self.description == PAINTBRUSH_SET_DESCRIPTION }
end end
def np? def np?
@ -273,19 +263,8 @@ class Item < ApplicationRecord
end end
def update_cached_fields def update_cached_fields
# First, clear out some cached instance variables we use for performance,
# to ensure we recompute the latest values.
@predicted_body_ids = nil
@predicted_missing_body_ids = nil
# We also need to reload our associations, so they include any new records.
swf_assets.reload
# Finally, compute and save our cached fields.
self.cached_occupied_zone_ids = occupied_zone_ids self.cached_occupied_zone_ids = occupied_zone_ids
self.cached_compatible_body_ids = compatible_body_ids(use_cached: false) self.cached_compatible_body_ids = compatible_body_ids(use_cached: false)
self.cached_predicted_fully_modeled =
predicted_fully_modeled?(use_cached: false)
self.save! self.save!
end end
@ -299,16 +278,8 @@ class Item < ApplicationRecord
write_attribute('species_support_ids', replacement) write_attribute('species_support_ids', replacement)
end end
def modeling_hinted_done?
modeling_status_hint == "done" || modeling_status_hint == "glitchy"
end
def predicted_body_ids def predicted_body_ids
@predicted_body_ids ||= if modeling_hinted_done? @predicted_body_ids ||= if compatible_body_ids.include?(0)
# If we've manually set this item to no longer report as needing modeling,
# predict that the current bodies are all of the compatible bodies.
compatible_body_ids
elsif compatible_body_ids.include?(0)
# Oh, look, it's already known to fit everybody! Sweet. We're done. (This # Oh, look, it's already known to fit everybody! Sweet. We're done. (This
# isn't folded into the case below, in case this item somehow got a # isn't folded into the case below, in case this item somehow got a
# body-specific and non-body-specific asset. In all the cases I've seen # body-specific and non-body-specific asset. In all the cases I've seen
@ -320,18 +291,7 @@ class Item < ApplicationRecord
# This might just be a species-specific item. Let's be conservative in # This might just be a species-specific item. Let's be conservative in
# our prediction, though we'll revise it if we see another body ID. # our prediction, though we'll revise it if we see another body ID.
compatible_body_ids compatible_body_ids
elsif compatible_body_ids.size == 0
# If somehow we have this item, but not any modeling data for it (weird!),
# consider it to fit all standard pet types until shown otherwise.
PetType.basic.released_before(released_at_estimate).
distinct.pluck(:body_id).sort
else else
# The core challenge: distinguish "item for Maraquan pets" from "item that
# happens to fit the Maraquan Mynci" (which shares a body with basic Myncis).
# We use a general rule: a color is "modelable" only if it has at least one
# *unique* body (not shared with other colors). This filters out false
# positives while remaining self-sustaining.
# First, find our compatible pet types, then pair each body ID with its # First, find our compatible pet types, then pair each body ID with its
# color. (As an optimization, we omit standard colors, other than the # color. (As an optimization, we omit standard colors, other than the
# basic colors. We also flatten the basic colors into the single color # basic colors. We also flatten the basic colors into the single color
@ -342,7 +302,6 @@ class Item < ApplicationRecord
Arel.sql("IF(colors.basic, 'basic', colors.id)"), :body_id) Arel.sql("IF(colors.basic, 'basic', colors.id)"), :body_id)
# Group colors by body, to help us find bodies unique to certain colors. # Group colors by body, to help us find bodies unique to certain colors.
# Example: {93 => ["basic"], 112 => ["maraquan"], 47 => ["basic", "maraquan"]}
compatible_color_ids_by_body_id = {}.tap do |h| compatible_color_ids_by_body_id = {}.tap do |h|
compatible_pairs.each do |(color_id, body_id)| compatible_pairs.each do |(color_id, body_id)|
h[body_id] ||= [] h[body_id] ||= []
@ -350,34 +309,25 @@ class Item < ApplicationRecord
end end
end end
# Find non-basic colors with at least one unique compatible body (size == 1). # Find non-basic colors with at least one unique compatible body. (This
# This means we'll predict "all Maraquan pets" only if the item fits a # means we'll ignore e.g. the Maraquan Mynci, which has the same body as
# Maraquan pet with a unique body (like the Maraquan Acara), not if it only # the Blue Mynci, as not indicating Maraquan compatibility in general.)
# fits the Maraquan Mynci (which shares its body with basic Myncis).
modelable_color_ids = modelable_color_ids =
compatible_color_ids_by_body_id. compatible_color_ids_by_body_id.
filter { |k, v| v.size == 1 && v.first != "basic" }. filter { |k, v| v.size == 1 && v.first != "basic" }.
values.map(&:first).uniq values.map(&:first).uniq
# We can model on basic pets if we find a basic body that doesn't also fit # We can model on basic pets (perhaps in addition to the above) if we
# any modelable colors. This way, if an item fits both basic Mynci and # find at least one compatible basic body that doesn't *also* fit any of
# Maraquan Acara (a modelable color), we treat it as "Maraquan item" not # the modelable colors we identified above.
# "basic item", avoiding false predictions for all basic pets.
basic_is_modelable = basic_is_modelable =
compatible_color_ids_by_body_id.values. compatible_color_ids_by_body_id.values.
any? { |v| v.include?("basic") && (v & modelable_color_ids).empty? } any? { |v| v.include?("basic") && (v & modelable_color_ids).empty? }
# Filter to pet types that match the colors that seem compatible. # Get all body IDs for the colors we decided are modelable.
predicted_pet_types = predicted_pet_types =
(basic_is_modelable ? PetType.basic : PetType.none). (basic_is_modelable ? PetType.basic : PetType.none).
or(PetType.where(color_id: modelable_color_ids)) or(PetType.where(color_id: modelable_color_ids))
# Only include species that were released when this item was. If we don't
# know our creation date (we don't have it for some old records), assume
# it's pretty old.
predicted_pet_types.merge! PetType.released_before(released_at_estimate)
# Get all body IDs for the pet types we decided are modelable.
predicted_pet_types.distinct.pluck(:body_id).sort predicted_pet_types.distinct.pluck(:body_id).sort
end end
end end
@ -429,8 +379,7 @@ class Item < ApplicationRecord
body_ids_by_species_by_color body_ids_by_species_by_color
end end
def predicted_fully_modeled?(use_cached: true) def predicted_fully_modeled?
return cached_predicted_fully_modeled? if use_cached
predicted_missing_body_ids.empty? predicted_missing_body_ids.empty?
end end
@ -438,40 +387,11 @@ class Item < ApplicationRecord
compatible_body_ids.size.to_f / predicted_body_ids.size compatible_body_ids.size.to_f / predicted_body_ids.size
end end
# We estimate the item's release time as either when we first saw it, or 2010
# if it's so old that we don't have a record.
def released_at_estimate
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={}) def as_json(options={})
result = super({ super({
only: [:id, :name, :description, :thumbnail_url, :rarity_index], only: [:id, :name, :description, :thumbnail_url, :rarity_index],
methods: [:zones_restrict], methods: [:zones_restrict],
}.merge(options)) }.merge(options))
if options[:include_trade_counts]
trades = visible_trades(
user: options[:current_user],
remote_ip: options[:remote_ip]
)
result['num_trades_offering'] = trades[:offering].size
result['num_trades_seeking'] = trades[:seeking].size
end
if options[:include_nc_trade_value]
result['nc_trade_value'] = nc_trade_value
end
result
end end
def compatible_body_ids(use_cached: true) def compatible_body_ids(use_cached: true)
@ -623,19 +543,22 @@ class Item < ApplicationRecord
Item.appearances_for([self], target, ...)[id] Item.appearances_for([self], target, ...)[id]
end end
def appearances_by_occupied_zone_label def appearances_by_occupied_zone_id
zones_by_id = occupied_zones.to_h { |z| [z.id, z] }
{}.tap do |h| {}.tap do |h|
appearances.each do |appearance| appearances.each do |appearance|
appearance.occupied_zone_ids.each do |zone_id| appearance.occupied_zone_ids.each do |zone_id|
zone_label = zones_by_id[zone_id].label h[zone_id] ||= []
h[zone_label] ||= [] h[zone_id] << appearance
h[zone_label] << appearance
end end
end end
end end
end end
def appearances_by_occupied_zone
zones_by_id = occupied_zones.to_h { |z| [z.id, z] }
appearances_by_occupied_zone_id.transform_keys { |zid| zones_by_id[zid] }
end
# Given a list of items, return how they look on the given target (either a # Given a list of items, return how they look on the given target (either a
# pet type or an alt style). # pet type or an alt style).
def self.appearances_for(items, target, swf_asset_includes: []) def self.appearances_for(items, target, swf_asset_includes: [])
@ -696,10 +619,21 @@ class Item < ApplicationRecord
end end
def self.preload_nc_trade_values(items) def self.preload_nc_trade_values(items)
DTIRequests.load_many(max_at_once: 10) do |task| # Only allow 10 trade values to be loaded at a time.
barrier = Async::Barrier.new
semaphore = Async::Semaphore.new(10, parent: barrier)
Sync do
# Load all the trade values in concurrent async tasks. (The # Load all the trade values in concurrent async tasks. (The
# `nc_trade_value` caches the value in the Item object.) # `nc_trade_value` caches the value in the Item object.)
items.each { |item| task.async { item.nc_trade_value } } items.each do |item|
semaphore.async { item.nc_trade_value }
end
# Wait until all tasks are done.
barrier.wait
ensure
barrier.stop # If something goes wrong, clean up all tasks.
end end
items items

View file

@ -5,7 +5,7 @@ class Item
end end
# Whether this is a Dyeworks item whose base item can currently be purchased # Whether this is a Dyeworks item whose base item can currently be purchased
# in the NC Mall, then dyed via Dyeworks. (Lebron tracks this last part!) # in the NC Mall, then dyed via Dyeworks. (Owls tracks this last part!)
def dyeworks_buyable? def dyeworks_buyable?
dyeworks_base_buyable? && dyeworks_dyeable? dyeworks_base_buyable? && dyeworks_dyeable?
end end
@ -18,14 +18,14 @@ class Item
end end
# Whether this is a Dyeworks item that can be dyed in the NC Mall ~right now, # Whether this is a Dyeworks item that can be dyed in the NC Mall ~right now,
# either at any time or as a limited-time event. (Lebron tracks this, not us!) # either at any time or as a limited-time event. (Owls tracks this, not us!)
def dyeworks_dyeable? def dyeworks_dyeable?
dyeworks_permanent? || dyeworks_limited_active? dyeworks_permanent? || dyeworks_limited_active?
end end
# Whether this is one of the few Dyeworks items that can be dyed in the NC # Whether this is one of the few Dyeworks items that can be dyed in the NC
# Mall at any time, rather than as part of a limited-time event. (Lebron # Mall at any time, rather than as part of a limited-time event. (Owls tracks
# tracks this, not us!) # this, not us!)
DYEWORKS_PERMANENT_PATTERN = /Permanent\s*Dyeworks/i DYEWORKS_PERMANENT_PATTERN = /Permanent\s*Dyeworks/i
def dyeworks_permanent? def dyeworks_permanent?
return false if nc_trade_value.nil? return false if nc_trade_value.nil?
@ -33,11 +33,11 @@ class Item
end end
# Whether this is a Dyeworks item that can be dyed in the NC Mall ~right # Whether this is a Dyeworks item that can be dyed in the NC Mall ~right
# now, as part of a limited-time event. (Lebron tracks this, not us!) # now, as part of a limited-time event. (Owls tracks this, not us!)
# #
# If we aren't sure of the final date, this will still return `true`, on # If we aren't sure of the final date, this will still return `true`, on
# the assumption it *is* dyeable right now and we just don't understand the # the assumption it *is* dyeable right now and we just don't understand the
# details of what Lebron told us. # details of what Owls told us.
def dyeworks_limited_active? def dyeworks_limited_active?
return false unless dyeworks_limited? return false unless dyeworks_limited?
return true if dyeworks_limited_final_date.nil? return true if dyeworks_limited_final_date.nil?
@ -51,8 +51,8 @@ class Item
# Whether this is a Dyeworks item that can only be dyed as part of a # Whether this is a Dyeworks item that can only be dyed as part of a
# limited-time event. (This may return true even if the end date has # limited-time event. (This may return true even if the end date has
# passed, see `dyeworks_limited_active?`.) (Lebron tracks this, not us!) # passed, see `dyeworks_limited_active?`.) (Owls tracks this, not us!)
DYEWORKS_LIMITED_PATTERN = /Dyeworks\s*Thru/i DYEWORKS_LIMITED_PATTERN = /Limited\s*Dyeworks/i
def dyeworks_limited? def dyeworks_limited?
return false if nc_trade_value.nil? return false if nc_trade_value.nil?
nc_trade_value.value_text.match?(DYEWORKS_LIMITED_PATTERN) nc_trade_value.value_text.match?(DYEWORKS_LIMITED_PATTERN)
@ -60,9 +60,9 @@ class Item
# If this is a limited-time Dyeworks item, this is the date we think the # If this is a limited-time Dyeworks item, this is the date we think the
# event will end on. Even if `dyeworks_limited?` returns true, this could # event will end on. Even if `dyeworks_limited?` returns true, this could
# still be `nil`, if we fail to parse this. (Lebron tracks this, not us!) # still be `nil`, if we fail to parse this. (Owls tracks this, not us!)
DYEWORKS_LIMITED_FINAL_DATE_PATTERN = DYEWORKS_LIMITED_FINAL_DATE_PATTERN =
/Dyeworks\s*Thru\s*(?<month>[a-z]+)\s*(?<day>[0-9]+)/i /Dyeable\s*Thru\s*(?<month>[a-z]+)\s*(?<day>[0-9]+)/i
def dyeworks_limited_final_date def dyeworks_limited_final_date
return nil unless dyeworks_limited? return nil unless dyeworks_limited?

View file

@ -132,8 +132,6 @@ class Item
is_positive ? Filter.is_np : Filter.is_not_np is_positive ? Filter.is_np : Filter.is_not_np
when 'pb' when 'pb'
is_positive ? Filter.is_pb : Filter.is_not_pb is_positive ? Filter.is_pb : Filter.is_not_pb
when 'modeled'
is_positive ? Filter.is_modeled : Filter.is_not_modeled
else else
raise_search_error "not_found.label", label: "is:#{value}" raise_search_error "not_found.label", label: "is:#{value}"
end end
@ -348,14 +346,6 @@ class Item
self.new Item.is_not_pb, '-is:pb' self.new Item.is_not_pb, '-is:pb'
end end
def self.is_modeled
self.new Item.is_modeled, 'is:modeled'
end
def self.is_not_modeled
self.new Item.is_not_modeled, '-is:modeled'
end
private private
# Add quotes around the value, if needed. # Add quotes around the value, if needed.

View file

@ -1,6 +1,5 @@
class NeopetsConnection < ApplicationRecord class NeopetsConnection < ApplicationRecord
belongs_to :user belongs_to :user
validates :neopets_username, uniqueness: {scope: :user_id}, validates :neopets_username, uniqueness: {scope: :user_id}
format: { without: /@/, message: 'must not be an email address, for user safety' }
end end

View file

@ -170,67 +170,47 @@ class Outfit < ApplicationRecord
end end
def visible_layers def visible_layers
# Step 1: Choose biology layers - use alt style if present, otherwise pet state item_appearances = item_appearances(swf_asset_includes: [:zone])
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
# Step 2: Load item appearances for the appropriate body pet_layers = pet_state.swf_assets.includes(:zone).to_a
item_appearances = Item.appearances_for(
worn_items,
body,
swf_asset_includes: [:zone]
).values
item_layers = item_appearances.map(&:swf_assets).flatten item_layers = item_appearances.map(&:swf_assets).flatten
# For alt styles, only body_id=0 items are compatible pet_restricted_zone_ids = pet_layers.map(&:restricted_zone_ids).
if using_alt_style
item_layers.reject! { |sa| sa.body_id != 0 }
end
# Step 3: Apply restriction rules
biology_restricted_zone_ids = biology_layers.map(&:restricted_zone_ids).
flatten.to_set flatten.to_set
item_restricted_zone_ids = item_appearances. item_restricted_zone_ids = item_appearances.
map(&:restricted_zone_ids).flatten.to_set map(&:restricted_zone_ids).flatten.to_set
# 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. # We use this to e.g. make a hat hide a hair ruff.
# #
# NOTE: Items' restricted layers also affect what items you can wear at # NOTE: Items' restricted layers also affect what items you can wear at
# the same time. We don't enforce anything about that here, and # the same time. We don't enforce anything about that here, and
# instead assume that the input by this point is valid! # instead assume that the input by this point is valid!
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 # When a pet appearance restricts a zone, or when the pet is Unconverted,
# Unconverted, it makes body-specific items incompatible. We use this to # it makes body-specific items incompatible. We use this to disallow UCs
# disallow UCs from wearing certain body-specific Biology Effects, Statics, # from wearing certain body-specific Biology Effects, Statics, etc, while
# etc, while still allowing non-body-specific items in those zones! (I think # still allowing non-body-specific items in those zones! (I think this
# this happens for some Invisible pet stuff, too?) # happens for some Invisible pet stuff, too?)
# #
# TODO: We shouldn't be *hiding* these zones, like we do with items; we # TODO: We shouldn't be *hiding* these zones, like we do with items; we
# should be doing this way earlier, to prevent the item from even # should be doing this way earlier, to prevent the item from even
# showing up even in search results! # showing up even in search results!
# #
# NOTE: This can result in both 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 # zone, like Static, so long as the item isn't body-specific! That's
# correct, and the item layer should be on top! (Here, we implement # correct, and the item layer should be on top! (Here, we implement
# it by placing item layers second in the list, and rely on JS sort # it by placing item layers second in the list, and rely on JS sort
# stability, and *then* rely on the UI to respect that ordering when # stability, and *then* rely on the UI to respect that ordering when
# rendering them by depth. Not great! 😅) # rendering them by depth. Not great! 😅)
# #
# NOTE: We used to also include the 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 # this condition, not just the restricted zones, as a sensible
# defensive default, even though we weren't aware of any relevant # defensive default, even though we weren't aware of any relevant
# items. But now we know that actually the "Bruce Brucey B Mouth" # items. But now we know that actually the "Bruce Brucey B Mouth"
# occupies the real Mouth zone, and still should be visible and # occupies the real Mouth zone, and still should be visible and
# above 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 # NOTE: UCs used to implement their restrictions by listing specific
# zones, but it seems that the logic has changed to just be about # zones, but it seems that the logic has changed to just be about
@ -247,20 +227,18 @@ class Outfit < ApplicationRecord
item_layers.reject! { |sa| sa.body_specific? } item_layers.reject! { |sa| sa.body_specific? }
else else
item_layers.reject! { |sa| sa.body_specific? && item_layers.reject! { |sa| sa.body_specific? &&
biology_restricted_zone_ids.include?(sa.zone_id) } pet_restricted_zone_ids.include?(sa.zone_id) }
end end
# Rule 3c: A biology appearance can also restrict its own zones. The Wraith # A pet appearance can also restrict its own zones. The Wraith Uni is an
# Uni is an interesting example: it has a horn, but its zone restrictions # interesting example: it has a horn, but its zone restrictions hide it!
# hide it! pet_layers.reject! { |sa| pet_restricted_zone_ids.include?(sa.zone_id) }
biology_layers.reject! { |sa| biology_restricted_zone_ids.include?(sa.zone_id) }
# Step 4: Sort by depth and return (pet_layers + item_layers).sort_by(&:depth)
(biology_layers + item_layers).sort_by(&:depth)
end end
def wardrobe_params def wardrobe_params
params = { {
name: name, name: name,
color: color_id, color: color_id,
species: species_id, species: species_id,
@ -269,8 +247,6 @@ class Outfit < ApplicationRecord
objects: worn_item_ids, objects: worn_item_ids,
closet: closeted_item_ids, closet: closeted_item_ids,
} }
params[:style] = alt_style_id if alt_style_id.present?
params
end end
def ensure_unique_name def ensure_unique_name

View file

@ -3,18 +3,71 @@ class Pet < ApplicationRecord
attr_reader :items, :pet_state, :alt_style attr_reader :items, :pet_state, :alt_style
def load!(timeout: nil) scope :with_pet_type_color_ids, ->(color_ids) {
raise ModelingDisabled unless Rails.configuration.modeling_enabled joins(:pet_type).where(PetType.arel_table[:id].in(color_ids))
}
viewer_data_hash = Neopets::CustomPets.fetch_viewer_data(name, timeout:) def load!(timeout: nil)
use_modeling_snapshot(ModelingSnapshot.new(viewer_data_hash)) viewer_data = Neopets::CustomPets.fetch_viewer_data(name, timeout:)
use_viewer_data(viewer_data)
end end
def use_modeling_snapshot(snapshot) def use_viewer_data(viewer_data)
self.pet_type = snapshot.pet_type pet_data = viewer_data[:custom_pet]
@pet_state = snapshot.pet_state
@alt_style = snapshot.alt_style raise UnexpectedDataFormat unless pet_data[:species_id]
@items = snapshot.items raise UnexpectedDataFormat unless pet_data[:color_id]
raise UnexpectedDataFormat unless pet_data[:body_id]
has_alt_style = pet_data[:alt_style].present?
self.pet_type = PetType.find_or_initialize_by(
species_id: pet_data[:species_id].to_i,
color_id: pet_data[:color_id].to_i
)
begin
new_image_hash = Neopets::CustomPets.fetch_image_hash(self.name)
rescue => error
Rails.logger.warn "Failed to load image hash: #{error.full_message}"
end
self.pet_type.image_hash = new_image_hash if new_image_hash.present?
# With an alt style, `body_id` in the biology data refers to the body ID of
# the *alt* style, not the usual pet type. (We have `original_biology` for
# *some* of the pet type's situation, but not it's body ID!)
#
# So, in the alt style case, don't update `body_id` - but if this is our
# first time seeing this pet type and it doesn't *have* a `body_id` yet,
# let's not be creating it without one. We'll need to model it without the
# alt style first. (I don't bother with a clear error message though 😅)
self.pet_type.body_id = pet_data[:body_id] unless has_alt_style
if self.pet_type.body_id.nil?
raise UnexpectedDataFormat,
"can't process alt style on first occurrence of pet type"
end
pet_state_biology = has_alt_style ? pet_data[:original_biology] :
pet_data[:biology_by_zone]
raise UnexpectedDataFormat if pet_state_biology.empty?
pet_state_biology[0] = nil # remove effects if present
@pet_state = self.pet_type.add_pet_state_from_biology! pet_state_biology
if has_alt_style
raise UnexpectedDataFormat unless pet_data[:alt_color]
raise UnexpectedDataFormat if pet_data[:biology_by_zone].empty?
@alt_style = AltStyle.find_or_initialize_by(id: pet_data[:alt_style].to_i)
@alt_style.assign_attributes(
color_id: pet_data[:alt_color].to_i,
species_id: pet_data[:species_id].to_i,
body_id: pet_data[:body_id].to_i,
biology: pet_data[:biology_by_zone],
)
end
@items = Item.collection_from_pet_type_and_registries(self.pet_type,
viewer_data[:object_info_registry], viewer_data[:object_asset_registry])
end end
def wardrobe_query def wardrobe_query
@ -40,8 +93,11 @@ class Pet < ApplicationRecord
before_validation do before_validation do
pet_type.save! pet_type.save!
@pet_state.save! if @pet_state if @pet_state
@pet_state.save!
@pet_state.handle_assets!
end
if @items if @items
@items.each do |item| @items.each do |item|
item.save! if item.changed? item.save! if item.changed?
@ -61,5 +117,5 @@ class Pet < ApplicationRecord
end end
class UnexpectedDataFormat < RuntimeError;end class UnexpectedDataFormat < RuntimeError;end
class ModelingDisabled < RuntimeError;end
end end

View file

@ -1,134 +0,0 @@
# A representation of a Neopets::CustomPets viewer data response, translated
# to DTI's database models!
class Pet::ModelingSnapshot
def initialize(viewer_data_hash)
@custom_pet = viewer_data_hash[:custom_pet]
@object_info_registry = viewer_data_hash[:object_info_registry]
@object_asset_registry = viewer_data_hash[:object_asset_registry]
end
def pet_type
@pet_type ||= begin
raise Pet::UnexpectedDataFormat unless @custom_pet[:species_id]
raise Pet::UnexpectedDataFormat unless @custom_pet[:color_id]
raise Pet::UnexpectedDataFormat unless @custom_pet[:body_id]
@custom_pet => {species_id:, color_id:}
PetType.find_or_initialize_by(species_id:, color_id:).tap do |pet_type|
# Apply the pet's body ID to the pet type, unless it's wearing an alt
# style, in which case ignore it, because it's the *alt style*'s body ID.
# (This can theoretically cause a problem saving a new pet type when
# there's an alt style too!)
pet_type.body_id = @custom_pet[:body_id] unless @custom_pet[:alt_style]
if pet_type.body_id.nil?
raise Pet::UnexpectedDataFormat,
"can't process alt style on first occurrence of pet type"
end
# Try using this pet for the pet type's thumbnail, but don't worry
# if it fails.
begin
pet_type.consider_pet_image(@custom_pet[:name])
rescue => error
Rails.logger.warn "Failed to load pet image: #{error.full_message}"
end
end
end
end
def pet_state
@pet_state ||= begin
swf_asset_ids = biology_assets.map(&:remote_id)
pet_type.pet_states.find_or_initialize_by(swf_asset_ids:).tap do |pet_state|
pet_state.swf_assets = biology_assets
end
end
end
def alt_style
@alt_style ||= begin
return nil unless @custom_pet[:alt_style]
raise Pet::UnexpectedDataFormat unless @custom_pet[:alt_color]
id = @custom_pet[:alt_style].to_i
AltStyle.find_or_initialize_by(id:).tap do |alt_style|
pet_name = @custom_pet[:name]
# Capture old asset IDs before assignment
old_asset_ids = alt_style.swf_assets.map(&:remote_id).sort
# Assign new attributes and assets
new_asset_ids = alt_style_assets.map(&:remote_id).sort
alt_style.assign_attributes(
color_id: @custom_pet[:alt_color].to_i,
species_id: @custom_pet[:species_id].to_i,
body_id: @custom_pet[:body_id].to_i,
swf_assets: alt_style_assets,
)
# Log the modeling event using Rails' change tracking
if alt_style.new_record?
Rails.logger.info "[Alt Style Modeling] Created alt style " \
"ID=#{id} for pet=#{pet_name}: " \
"species_id=#{alt_style.species_id}, " \
"color_id=#{alt_style.color_id}, " \
"body_id=#{alt_style.body_id}, " \
"asset_ids=#{new_asset_ids.inspect}"
elsif alt_style.changes.any? || old_asset_ids != new_asset_ids
changes = []
changes << "species_id: #{alt_style.species_id_was} -> #{alt_style.species_id}" if alt_style.species_id_changed?
changes << "color_id: #{alt_style.color_id_was} -> #{alt_style.color_id}" if alt_style.color_id_changed?
changes << "body_id: #{alt_style.body_id_was} -> #{alt_style.body_id}" if alt_style.body_id_changed?
changes << "asset_ids: #{old_asset_ids.inspect} -> #{new_asset_ids.inspect}" if old_asset_ids != new_asset_ids
Rails.logger.warn "[Alt Style Modeling] Updated alt style " \
"ID=#{id} for pet=#{pet_name}. " \
"CHANGED: #{changes.join(', ')}"
else
Rails.logger.info "[Alt Style Modeling] Loaded alt style " \
"ID=#{id} for pet=#{pet_name} (no changes)"
end
end
end
end
def items
@items ||= Item.collection_from_pet_type_and_registries(
pet_type, @object_info_registry, @object_asset_registry
)
end
private
def biology_assets
@biology_assets ||= begin
biology = @custom_pet[:alt_style].present? ?
@custom_pet[:original_biology] :
@custom_pet[:biology_by_zone]
assets_from_biology(biology)
end
end
def item_assets_for(item_id)
all_infos = @object_asset_registry.values
infos = all_infos.select { |a| a[:obj_info_id].to_i == item_id.to_i }
infos.map do |asset_data|
remote_id = asset_data[:asset_id].to_i
SwfAsset.find_or_initialize_by(type: "object", remote_id:).tap do |swf_asset|
swf_asset.origin_pet_type = pet_type
swf_asset.origin_object_data = asset_data
end
end
end
def alt_style_assets
raise Pet::UnexpectedDataFormat if @custom_pet[:biology_by_zone].empty?
assets_from_biology(@custom_pet[:biology_by_zone])
end
def assets_from_biology(biology)
raise Pet::UnexpectedDataFormat if biology.empty?
body_id = @custom_pet[:body_id].to_i
biology.values.map { |b| SwfAsset.from_biology_data(body_id, b) }
end
end

View file

@ -6,25 +6,17 @@ class PetState < ApplicationRecord
has_many :contributions, :as => :contributed, has_many :contributions, :as => :contributed,
:inverse_of => :contributed # in case of duplicates being merged :inverse_of => :contributed # in case of duplicates being merged
has_many :outfits has_many :outfits
has_many :parent_swf_asset_relationships, :as => :parent has_many :parent_swf_asset_relationships, :as => :parent,
:autosave => false
has_many :swf_assets, :through => :parent_swf_asset_relationships has_many :swf_assets, :through => :parent_swf_asset_relationships
serialize :swf_asset_ids, coder: Serializers::IntegerSet, type: Array
belongs_to :pet_type belongs_to :pet_type
delegate :species_id, :species, :color_id, :color, to: :pet_type delegate :species_id, :species, :color_id, :color, to: :pet_type
alias_method :swf_asset_ids_from_association, :swf_asset_ids alias_method :swf_asset_ids_from_association, :swf_asset_ids
scope :glitched, -> { where(glitched: true) } attr_writer :parent_swf_asset_relationships_to_update
scope :needs_labeling, -> { unlabeled.where(glitched: false) }
scope :unlabeled, -> { with_pose("UNKNOWN") }
scope :usable, -> { where(labeled: true, glitched: false) }
scope :newest, -> { order(created_at: :desc) }
scope :newest_pet_type, -> { joins(:pet_type).merge(PetType.newest) }
scope :created_before, ->(time) { where(arel_table[:created_at].lt(time)) }
# A simple ordering that tries to bring reliable pet states to the front. # A simple ordering that tries to bring reliable pet states to the front.
scope :emotion_order, -> { scope :emotion_order, -> {
@ -103,16 +95,109 @@ class PetState < ApplicationRecord
end end
end end
def reassign_children_to!(main_pet_state)
self.contributions.each do |contribution|
contribution.contributed = main_pet_state
contribution.save
end
self.outfits.each do |outfit|
outfit.pet_state = main_pet_state
outfit.save
end
ParentSwfAssetRelationship.where(ParentSwfAssetRelationship.arel_table[:parent_id].eq(self.id)).delete_all
end
def reassign_duplicates!
raise "This may only be applied to pet states that represent many duplicate entries" unless duplicate_ids
pet_states = duplicate_ids.split(',').map do |id|
PetState.find(id.to_i)
end
main_pet_state = pet_states.shift
pet_states.each do |pet_state|
pet_state.reassign_children_to!(main_pet_state)
pet_state.destroy
end
end
def sort_swf_asset_ids!
self.swf_asset_ids = swf_asset_ids_array.sort.join(',')
end
def swf_asset_ids
self['swf_asset_ids']
end
def swf_asset_ids_array
swf_asset_ids.split(',').map(&:to_i)
end
def swf_asset_ids=(ids)
self['swf_asset_ids'] = ids
end
def handle_assets!
@parent_swf_asset_relationships_to_update.each do |rel|
rel.swf_asset.save!
rel.save!
end
end
def to_param def to_param
"#{id}-#{pose.split('_').map(&:capitalize).join('-')}" "#{id}-#{pose.split('_').map(&:capitalize).join('-')}"
end end
# Because our column is named `swf_asset_ids`, we need to ensure writes to def self.from_pet_type_and_biology_info(pet_type, info)
# it go to the attribute, and not the thing ActiveRecord does of finding the swf_asset_ids = []
# relevant `swf_assets`. info.each do |zone_id, asset_info|
# TODO: Consider renaming the column to `cached_swf_asset_ids`? if zone_id.present? && asset_info
def swf_asset_ids=(new_swf_asset_ids) swf_asset_ids << asset_info[:part_id].to_i
write_attribute(:swf_asset_ids, new_swf_asset_ids) end
end
swf_asset_ids_str = swf_asset_ids.sort.join(',')
if pet_type.new_record?
pet_state = self.new :swf_asset_ids => swf_asset_ids_str
else
pet_state = self.find_or_initialize_by(
pet_type_id: pet_type.id,
swf_asset_ids: swf_asset_ids_str
)
end
existing_swf_assets = SwfAsset.biology_assets.includes(:zone).
where(remote_id: swf_asset_ids)
existing_swf_assets_by_id = {}
existing_swf_assets.each do |swf_asset|
existing_swf_assets_by_id[swf_asset.remote_id] = swf_asset
end
existing_relationships_by_swf_asset_id = {}
unless pet_state.new_record?
pet_state.parent_swf_asset_relationships.each do |relationship|
existing_relationships_by_swf_asset_id[relationship.swf_asset_id] = relationship
end
end
pet_state.pet_type = pet_type # save the second case from having to look it up by ID
relationships = []
info.each do |zone_id, asset_info|
if zone_id.present? && asset_info
swf_asset_id = asset_info[:part_id].to_i
swf_asset = existing_swf_assets_by_id[swf_asset_id]
unless swf_asset
swf_asset = SwfAsset.new
swf_asset.remote_id = swf_asset_id
end
swf_asset.origin_biology_data = asset_info
swf_asset.origin_pet_type = pet_type
relationship = existing_relationships_by_swf_asset_id[swf_asset.id]
unless relationship
relationship ||= ParentSwfAssetRelationship.new
relationship.parent = pet_state
relationship.swf_asset_id = swf_asset.id
end
relationship.swf_asset = swf_asset
relationships << relationship
end
end
pet_state.parent_swf_asset_relationships_to_update = relationships
pet_state
end end
private private
@ -142,40 +227,5 @@ class PetState < ApplicationRecord
end end
end end
end end
def self.next_unlabeled_appearance(after_id: nil)
# Rather than just getting the newest unlabeled pet state, prioritize the
# newest *pet type*. This better matches the user's perception of what the
# newest state is, because the Rainbow Pool UI is grouped by pet type!
pet_states = needs_labeling.newest_pet_type.newest
# If `after_id` is given, convert it from a PetState ID to creation
# timestamps, and find the next record prior to those timestamps. This
# enables skipping past records the user doesn't want to label.
if after_id
begin
after_pet_state = PetState.find(after_id)
before_pt_created_at = after_pet_state.pet_type.created_at
before_ps_created_at = after_pet_state.created_at
rescue ActiveRecord::RecordNotFound
Rails.logger.warn "PetState.next_unlabeled_appearance: Could not " +
"find pet state ##{after_id}"
return nil
end
# Because we sort by `newest_pet_type` first, then breaks ties by
# `newest`, our filter needs to operate the same way. Kudos to:
# https://brunoscheufler.com/blog/2022-01-01-paginating-large-ordered-datasets-with-cursor-based-pagination
pet_states.merge!(
PetType.created_before(before_pt_created_at).or(
PetType.created_at(before_pt_created_at).and(
PetState.created_before(before_ps_created_at)
)
)
)
end
pet_states.first
end
end end

View file

@ -15,7 +15,10 @@ class PetType < ApplicationRecord
species = Species.find_by_name!(species_name) species = Species.find_by_name!(species_name)
where(color_id: color.id, species_id: species.id) where(color_id: color.id, species_id: species.id)
} }
scope :newest, -> { order(created_at: :desc) } scope :matching_name_param, ->(name_param) {
color_name, _, species_name = name_param.rpartition("-")
matching_name(color_name, species_name)
}
scope :preferring_species, ->(species_id) { scope :preferring_species, ->(species_id) {
joins(:species).order([Arel.sql("species_id = ? DESC"), species_id]) joins(:species).order([Arel.sql("species_id = ? DESC"), species_id])
} }
@ -27,16 +30,6 @@ class PetType < ApplicationRecord
merge(Species.order(name: :asc)). merge(Species.order(name: :asc)).
merge(Color.order(basic: :desc, standard: :desc, name: :asc)) merge(Color.order(basic: :desc, standard: :desc, name: :asc))
} }
scope :released_before, ->(time) {
# We use DTI's creation timestamp as an estimate of when it was released.
where('created_at <= ?', time)
}
scope :created_before, ->(time) {
where(arel_table[:created_at].lt(time))
}
scope :created_at, ->(time) {
where(arel_table[:created_at].eq(time))
}
def self.random_basic_per_species(species_ids) def self.random_basic_per_species(species_ids)
random_pet_types = [] random_pet_types = []
@ -64,14 +57,6 @@ class PetType < ApplicationRecord
basic_image_hash || self['image_hash'] || 'deadbeef' basic_image_hash || self['image_hash'] || 'deadbeef'
end end
def consider_pet_image(pet_name)
# If we already have a basic image hash, don't worry about it!
return if basic_image_hash?
# Otherwise, use this as the new image hash for this pet type.
self.image_hash = Neopets::CustomPets.fetch_image_hash(pet_name)
end
def possibly_new_color def possibly_new_color
self.color || Color.new(id: self.color_id) self.color || Color.new(id: self.color_id)
end end
@ -86,6 +71,11 @@ class PetType < ApplicationRecord
species_human_name: possibly_new_species.human_name) species_human_name: possibly_new_species.human_name)
end end
def add_pet_state_from_biology!(biology)
pet_state = PetState.from_pet_type_and_biology_info(self, biology)
pet_state
end
def canonical_pet_state def canonical_pet_state
# For consistency (randomness is always scary!), we use the PetType ID to # For consistency (randomness is always scary!), we use the PetType ID to
# determine which gender to prefer, if it's not built into the color. That # determine which gender to prefer, if it's not built into the color. That
@ -123,7 +113,7 @@ class PetType < ApplicationRecord
end end
def to_param def to_param
"#{possibly_new_color.to_param}-#{possibly_new_species.to_param}" "#{color.human_name}-#{species.human_name}"
end end
def fully_labeled? def fully_labeled?
@ -143,19 +133,6 @@ class PetType < ApplicationRecord
pet_states.count { |ps| ps.pose == "UNKNOWN" } pet_states.count { |ps| ps.pose == "UNKNOWN" }
end end
def reference
PetType.where(species_id: species).basic.merge(Color.alphabetical).first
end
def self.find_by_param!(param)
raise ActiveRecord::RecordNotFound unless param.include?("-")
color_param, _, species_param = param.rpartition("-")
where(
color_id: Color.param_to_id(color_param),
species_id: Species.param_to_id(species_param),
).first!
end
def self.basic_body_ids def self.basic_body_ids
PetType.basic.distinct.pluck(:body_id) PetType.basic.distinct.pluck(:body_id)
end end

View file

@ -16,10 +16,6 @@ class Species < ApplicationRecord
end end
end end
def to_param
name? ? human_name : id.to_s
end
# Given a list of body IDs, return a hash from body ID to Species. # Given a list of body IDs, return a hash from body ID to Species.
# (We assume that each body ID belongs to just one species; if not, which # (We assume that each body ID belongs to just one species; if not, which
# species we return for that body ID is undefined.) # species we return for that body ID is undefined.)
@ -30,8 +26,4 @@ class Species < ApplicationRecord
to_h { |s| [s.id, s] } to_h { |s| [s.id, s] }
species_ids_by_body_id.transform_values { |id| species_by_id[id] } species_ids_by_body_id.transform_values { |id| species_by_id[id] }
end end
def self.param_to_id(param)
param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id
end
end end

View file

@ -1,4 +1,7 @@
require 'addressable/template' require 'addressable/template'
require 'async'
require 'async/barrier'
require 'async/semaphore'
class SwfAsset < ApplicationRecord class SwfAsset < ApplicationRecord
# We use the `type` column to mean something other than what Rails means! # We use the `type` column to mean something other than what Rails means!
@ -38,7 +41,7 @@ class SwfAsset < ApplicationRecord
{ {
swf: url, swf: url,
png: image_url, png: image_url,
svg: svg_url, svg: manifest_asset_urls[:svg],
canvas_library: manifest_asset_urls[:js], canvas_library: manifest_asset_urls[:js],
manifest: manifest_url, manifest: manifest_url,
} }
@ -183,18 +186,6 @@ class SwfAsset < ApplicationRecord
nil nil
end end
def image_url?
image_url.present?
end
def svg_url
manifest_asset_urls[:svg]
end
def svg_url?
svg_url.present?
end
def canvas_movie? def canvas_movie?
canvas_movie_library_url.present? canvas_movie_library_url.present?
end end
@ -329,12 +320,30 @@ class SwfAsset < ApplicationRecord
swf_asset swf_asset
end end
def self.from_wardrobe_link_params(ids)
where((
arel_table[:remote_id].in(ids[:biology]).and(arel_table[:type].eq('biology'))
).or(
arel_table[:remote_id].in(ids[:object]).and(arel_table[:type].eq('object'))
))
end
# Given a list of SWF assets, ensure all of their manifests are loaded, with # Given a list of SWF assets, ensure all of their manifests are loaded, with
# fast concurrent execution! # fast concurrent execution!
def self.preload_manifests(swf_assets) def self.preload_manifests(swf_assets)
DTIRequests.load_many(max_at_once: 10) do |task| # Blocks all tasks beneath it.
swf_assets.each do |swf_asset| barrier = Async::Barrier.new
task.async do
Sync do
# Only allow 10 manifests to be loaded at a time.
semaphore = Async::Semaphore.new(10, parent: barrier)
# Load all the manifests in async tasks. This will load them 10 at a time
# rather than all at once (because of the semaphore), and the
# NeopetsMediaArchive will share a pool of persistent connections for
# them.
swf_assets.map do |swf_asset|
semaphore.async do
begin begin
# Don't save changes in this big async situation; we'll do it all # Don't save changes in this big async situation; we'll do it all
# in one batch after, to avoid too much database concurrency! # in one batch after, to avoid too much database concurrency!
@ -345,6 +354,11 @@ class SwfAsset < ApplicationRecord
end end
end end
end end
# Wait until all tasks are done.
barrier.wait
ensure
barrier.stop # If something goes wrong, clean up all tasks.
end end
SwfAsset.transaction do SwfAsset.transaction do

View file

@ -198,17 +198,6 @@ class User < ApplicationRecord
touch(:last_trade_activity_at) touch(:last_trade_activity_at)
end end
def visible_to?(current_user, remote_ip)
# Everyone is visible to support staff.
return true if current_user&.support_staff?
# Shadowbanned users are only visible to themselves.
return false if shadowbanned? && !likely_is?(current_user, remote_ip)
# Other than that, users are visible to everyone by default.
return true
end
def self.points_required_to_pass_top_contributor(offset) def self.points_required_to_pass_top_contributor(offset)
user = User.top_contributors.select(:points).limit(1).offset(offset).first user = User.top_contributors.select(:points).limit(1).offset(offset).first
user ? user.points : 0 user ? user.points : 0

View file

@ -1,50 +0,0 @@
module LebronNCValues
# NOTE: While we still have `updated_at` infra lying around from the Owls
# days, the current JSON file doesn't tell us that info, so it's always
# `nil` for now.
Value = Struct.new(:value_text, :updated_at)
class Error < StandardError;end
class NetworkError < Error;end
class NotFound < Error;end
class << self
def find_by_name(name)
value_text = all_values[name.downcase]
raise NotFound if value_text.nil? || value_text.strip == '-'
Value.new(value_text, nil)
end
private
def all_values
Rails.cache.fetch('LebronNCValues.load_all_values', expires_in: 1.day) do
Sync { load_all_values }
end
end
ALL_VALUES_URL = "https://lebron-values.netlify.app/item_values.json"
def load_all_values
begin
DTIRequests.get(ALL_VALUES_URL) do |response|
if response.status != 200
raise NetworkError,
"Lebron returned status code #{response.status} (expected 200)"
end
begin
JSON.parse(response.read)
rescue JSON::ParserError => error
raise NetworkError,
"Lebron returned unsupported data format: #{error.message}"
end
end
rescue Async::TimeoutError => error
raise NetworkError, "Lebron timed out: #{error.message}"
rescue SocketError => error
raise NetworkError, "Could not connected to Lebron: #{error.message}"
end
end
end
end

View file

@ -49,7 +49,9 @@ module Neopets::CustomPets
# Return the response body as a `HashWithIndifferentAccess`. # Return the response body as a `HashWithIndifferentAccess`.
def send_amfphp_request(request, timeout: 10) def send_amfphp_request(request, timeout: 10)
begin begin
response_data = request.post(timeout: timeout) response_data = request.post(timeout: timeout, headers: {
"User-Agent" => Rails.configuration.user_agent_for_neopets,
})
rescue RocketAMFExtensions::RemoteGateway::AMFError => e rescue RocketAMFExtensions::RemoteGateway::AMFError => e
raise DownloadError, e.message raise DownloadError, e.message
rescue RocketAMFExtensions::RemoteGateway::ConnectionError => e rescue RocketAMFExtensions::RemoteGateway::ConnectionError => e

View file

@ -1,39 +1,57 @@
require "addressable/template" require "addressable/template"
require "async/http/internet/instance"
# Neopets::NCMall integrates with the Neopets NC Mall to fetch currently
# available items and their pricing.
#
# The integration works in two steps:
#
# 1. Category Discovery: We fetch the NC Mall homepage and extract the
# browsable categories from the embedded `window.ncmall_menu` JSON data.
# We filter out special feature categories (those with external URLs) and
# structural parent nodes (those without a cat_id).
#
# 2. Item Fetching: For each category, we call the v2 category API with
# pagination support. Large categories may span multiple pages, which we
# fetch in parallel and combine. Items can appear in multiple categories,
# so the rake task de-duplicates by item ID.
#
# The parsed item data includes:
# - id: Neopets item ID
# - name: Item display name
# - description: Item description
# - price: Regular price in NC (NeoCash)
# - discount: Optional discount info (price, begins_at, ends_at)
# - is_available: Whether the item is currently purchasable
#
# This module is used by the `neopets:import:nc_mall` rake task to sync our
# NCMallRecord table with the live NC Mall.
module Neopets::NCMall module Neopets::NCMall
# Load the NC Mall page for a specific type and category ID, with pagination. # Share a pool of persistent connections, rather than reconnecting on
# each request. (This library does that automatically!)
INTERNET = Async::HTTP::Internet.instance
# Load the NC Mall home page content area, and return its useful data.
HOME_PAGE_URL = "https://ncmall.neopets.com/mall/ajax/home_page.phtml"
def self.load_home_page
load_page_by_url HOME_PAGE_URL
end
# Load the NC Mall page for a specific type and category ID.
CATEGORY_PAGE_URL_TEMPLATE = Addressable::Template.new( CATEGORY_PAGE_URL_TEMPLATE = Addressable::Template.new(
"https://ncmall.neopets.com/mall/ajax/v2/category/index.phtml{?type,cat,page,limit}" "https://ncmall.neopets.com/mall/ajax/load_page.phtml?lang=en{&type,cat}"
) )
def self.load_page(type, cat, page: 1, limit: 24) def self.load_page(type, cat)
url = CATEGORY_PAGE_URL_TEMPLATE.expand(type:, cat:, page:, limit:) load_page_by_url CATEGORY_PAGE_URL_TEMPLATE.expand(type:, cat:)
end
# Load the NC Mall root document HTML, and extract the list of links to
# other pages ("New", "Popular", etc.)
ROOT_DOCUMENT_URL = "https://ncmall.neopets.com/mall/shop.phtml"
PAGE_LINK_PATTERN = /load_items_pane\(['"](.+?)['"], ([0-9]+)\).+?>(.+?)</
def self.load_page_links
html = Sync do
INTERNET.get(ROOT_DOCUMENT_URL, [
["User-Agent", Rails.configuration.user_agent_for_neopets],
]) do |response|
if response.status != 200
raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{url})"
end
response.read
end
end
# Extract `load_items_pane` calls from the root document's HTML. (We use
# a very simplified regex, rather than actually parsing the full HTML!)
html.scan(PAGE_LINK_PATTERN).
map { |type, cat, label| {type:, cat:, label:} }.
uniq
end
private
def self.load_page_by_url(url)
Sync do Sync do
DTIRequests.get(url) do |response| INTERNET.get(url, [
["User-Agent", Rails.configuration.user_agent_for_neopets],
]) do |response|
if response.status != 200 if response.status != 200
raise ResponseNotOK.new(response.status), raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{url})" "expected status 200 but got #{response.status} (#{url})"
@ -44,174 +62,7 @@ module Neopets::NCMall
end end
end end
# Load all pages for a specific category. # Given a string of NC page data, parse the useful data out of it!
def self.load_category_all_pages(type, cat, limit: 24)
# First, load page 1 to get total page count
first_page = load_page(type, cat, page: 1, limit:)
total_pages = first_page[:total_pages]
# If there's only one page, return it
return first_page[:items] if total_pages <= 1
# Otherwise, load remaining pages in parallel
Sync do
remaining_page_tasks = (2..total_pages).map do |page_num|
Async { load_page(type, cat, page: page_num, limit:) }
end
all_pages = [first_page] + remaining_page_tasks.map(&:wait)
all_pages.flat_map { |page| page[:items] }
end
end
# Load the NC Mall root document HTML, and extract categories from the
# embedded menu JSON.
ROOT_DOCUMENT_URL = "https://ncmall.neopets.com/mall/shop.phtml"
MENU_JSON_PATTERN = /window\.ncmall_menu = (\[.*?\]);/m
def self.load_categories
html = Sync do
DTIRequests.get(ROOT_DOCUMENT_URL) do |response|
if response.status != 200
raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{ROOT_DOCUMENT_URL})"
end
response.read
end
end
# Extract the ncmall_menu JSON from the script tag
match = html.match(MENU_JSON_PATTERN)
unless match
raise UnexpectedResponseFormat,
"could not find window.ncmall_menu in homepage HTML"
end
begin
menu = JSON.parse(match[1])
rescue JSON::ParserError => e
Rails.logger.debug "Failed to parse ncmall_menu JSON: #{e.message}"
raise UnexpectedResponseFormat,
"failed to parse ncmall_menu as JSON"
end
# Flatten the menu structure, and filter to browsable categories
browsable_categories = flatten_categories(menu).
# Skip categories without a cat_id (structural parent nodes)
reject { |cat| cat['cat_id'].blank? }.
# Skip categories with external URLs (special features)
reject { |cat| cat['url'].present? }
# Map each category to include the API type (and remove load_type)
browsable_categories.map do |cat|
cat.except("load_type").merge(
"type" => map_load_type_to_api_type(cat["load_type"])
)
end
end
def self.load_styles(species_id:, neologin:)
Sync do
tabs = [
Async { load_styles_tab(species_id:, neologin:, tab: 1) },
Async { load_styles_tab(species_id:, neologin:, tab: 2) },
]
tabs.map(&:wait).flatten(1)
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(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
{newsci: data["newsci"]}
end
end
end
private
# Map load_type from menu JSON to the v2 API type parameter.
def self.map_load_type_to_api_type(load_type)
case load_type
when "new"
"new_items"
when "popular"
"popular_items"
else
"browse"
end
end
# Flatten nested category structure (handles children arrays)
def self.flatten_categories(menu)
menu.flat_map do |cat|
children = cat["children"] || []
[cat] + flatten_categories(children)
end
end
STYLING_STUDIO_URL = "https://www.neopets.com/np-templates/ajax/stylingstudio/studio.php"
def self.load_styles_tab(species_id:, neologin:, tab:)
Sync do
DTIRequests.post(
STYLING_STUDIO_URL,
[
["Content-Type", "application/x-www-form-urlencoded"],
["Cookie", "neologin=#{neologin}"],
["X-Requested-With", "XMLHttpRequest"],
],
{tab:, mode: "getAvailable", species: species_id}.to_query,
) do |response|
if response.status != 200
raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{STYLING_STUDIO_URL})"
end
begin
data = JSON.parse(response.read).deep_symbolize_keys
# HACK: styles is a hash, unless it's empty, in which case it's an
# array? Weird. Normalize this by converting to hash.
data.fetch(:styles).to_h.values.
map { |s| s.slice(:oii, :name, :image, :limited) }
rescue JSON::ParserError, KeyError
raise UnexpectedResponseFormat
end
end
end
end
# Given a string of v2 NC page data, parse the useful data out of it!
def self.parse_nc_page(nc_page_str) def self.parse_nc_page(nc_page_str)
begin begin
nc_page = JSON.parse(nc_page_str) nc_page = JSON.parse(nc_page_str)
@ -221,14 +72,24 @@ module Neopets::NCMall
"failed to parse NC page response as JSON" "failed to parse NC page response as JSON"
end end
# v2 API returns items in a "data" array unless nc_page.has_key? "object_data"
unless nc_page.has_key? "data" raise UnexpectedResponseFormat, "missing field object_data in NC page"
raise UnexpectedResponseFormat, "missing field data in v2 NC page"
end end
item_data = nc_page["data"] || [] object_data = nc_page["object_data"]
items = item_data.map do |item_info| # NOTE: When there's no object data, it will be an empty array instead of
# an empty hash. Weird API thing to work around!
object_data = {} if object_data == []
# Only the items in the `render` list are actually listed as directly for
# sale in the shop. `object_data` might contain other items that provide
# supporting information about them, but aren't actually for sale.
visible_object_data = (nc_page["render"] || []).
map { |id| object_data[id.to_s] }.
filter(&:present?)
items = visible_object_data.map do |item_info|
{ {
id: item_info["id"], id: item_info["id"],
name: item_info["name"], name: item_info["name"],
@ -239,24 +100,18 @@ module Neopets::NCMall
} }
end end
{ {items:}
items:,
total_pages: nc_page["totalPages"].to_i,
page: nc_page["page"].to_i,
limit: nc_page["limit"].to_i,
}
end end
# Given item info, return a hash of discount-specific info, if any. # Given item info, return a hash of discount-specific info, if any.
NST = Time.find_zone("Pacific Time (US & Canada)")
def self.parse_item_discount(item_info) def self.parse_item_discount(item_info)
discount_price = item_info["discountPrice"] discount_price = item_info["discountPrice"]
return nil unless discount_price.present? && discount_price > 0 return nil unless discount_price.present? && discount_price > 0
{ {
price: discount_price, price: discount_price,
begins_at: NST.at(item_info["discountBegin"]), begins_at: item_info["discountBegin"],
ends_at: NST.at(item_info["discountEnd"]), ends_at: item_info["discountEnd"],
} }
end end

View file

@ -1,6 +1,12 @@
require "async/http/internet/instance"
# While most of our NeoPass logic is built into Devise -> OmniAuth -> OIDC # While most of our NeoPass logic is built into Devise -> OmniAuth -> OIDC
# OmniAuth plugin, NeoPass also offers some supplemental APIs that we use here. # OmniAuth plugin, NeoPass also offers some supplemental APIs that we use here.
module Neopets::NeoPass module Neopets::NeoPass
# Share a pool of persistent connections, rather than reconnecting on
# each request. (This library does that automatically!)
INTERNET = Async::HTTP::Internet.instance
def self.load_main_neopets_username(access_token) def self.load_main_neopets_username(access_token)
linkages = load_linkages(access_token) linkages = load_linkages(access_token)
@ -26,10 +32,10 @@ module Neopets::NeoPass
LINKAGE_URL = "https://oidc.neopets.com/linkage/all" LINKAGE_URL = "https://oidc.neopets.com/linkage/all"
def self.load_linkages(access_token) def self.load_linkages(access_token)
linkages_str = Sync do linkages_str = Sync do
DTIRequests.get( INTERNET.get(LINKAGE_URL, [
LINKAGE_URL, ["User-Agent", Rails.configuration.user_agent_for_neopets],
[["Authorization", "Bearer #{access_token}"]], ["Authorization", "Bearer #{access_token}"],
) do |response| ]) do |response|
if response.status != 200 if response.status != 200
raise ResponseNotOK.new(response.status), raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{LINKAGE_URL})" "expected status 200 but got #{response.status} (#{LINKAGE_URL})"

View file

@ -1,4 +1,5 @@
require "addressable/uri" require "addressable/uri"
require "async/http/internet/instance"
require "json" require "json"
# The Neopets Media Archive is a service that mirrors images.neopets.com files # The Neopets Media Archive is a service that mirrors images.neopets.com files
@ -10,6 +11,10 @@ require "json"
# long-term archive, not dependent on their services having 100% uptime in # long-term archive, not dependent on their services having 100% uptime in
# order for us to operate. We never discard old files, we just keep going! # order for us to operate. We never discard old files, we just keep going!
module NeopetsMediaArchive module NeopetsMediaArchive
# Share a pool of persistent connections, rather than reconnecting on
# each request. (This library does that automatically!)
INTERNET = Async::HTTP::Internet.instance
ROOT_PATH = Pathname.new(Rails.configuration.neopets_media_archive_root) ROOT_PATH = Pathname.new(Rails.configuration.neopets_media_archive_root)
# Load the file from the given `images.neopets.com` URI. # Load the file from the given `images.neopets.com` URI.
@ -67,7 +72,9 @@ module NeopetsMediaArchive
# We use this in the `swf_assets:manifests:load` task to perform many # We use this in the `swf_assets:manifests:load` task to perform many
# requests in parallel! # requests in parallel!
Sync do Sync do
DTIRequests.get(uri) do |response| INTERNET.get(uri, [
["User-Agent", Rails.configuration.user_agent_for_neopets],
]) do |response|
if response.status != 200 if response.status != 200
raise ResponseNotOK.new(response.status), raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{uri})" "expected status 200 but got #{response.status} (#{uri})"

View file

@ -0,0 +1,77 @@
module OwlsValueGuide
include HTTParty
ITEMDATA_URL_TEMPLATE = Addressable::Template.new(
"https://neo-owls.net/itemdata/{item_name}"
)
def self.find_by_name(item_name)
# Load the itemdata, pulling from the Rails cache if possible.
cache_key = "OwlsValueGuide/itemdata/#{item_name}"
data = Rails.cache.fetch(cache_key, expires_in: 1.day) do
load_itemdata(item_name)
end
if data == :not_found
raise NotFound
end
# Owls has records of some items that it explicitly marks as having no
# listed value. We don't care about that distinction, just return nil!
return nil if data['owls_value'].blank?
Value.new(data['owls_value'], parse_last_updated(data['last_updated']))
end
Value = Struct.new(:value_text, :updated_at)
class Error < StandardError;end
class NetworkError < Error;end
class NotFound < Error;end
private
def self.load_itemdata(item_name)
Rails.logger.info "[OwlsValueGuide] Loading value for #{item_name.inspect}"
url = ITEMDATA_URL_TEMPLATE.expand(item_name: item_name)
begin
res = get(url, headers: {
"User-Agent" => Rails.configuration.user_agent_for_neopets,
})
rescue StandardError => error
raise NetworkError, "Couldn't connect to Owls: #{error.message}"
end
if res.code == 404
# Instead of raising immediately, return `:not_found` to save this
# result in the cache, then raise *after* we exit the cache block. That
# way, we won't make repeat requests for items we have that Owls
# doesn't.
return :not_found
end
if res.code != 200
raise NetworkError, "Owls returned status code #{res.code} (expected 200)"
end
begin
res.parsed_response
rescue HTTParty::Error => error
raise NetworkError, "Owls returned unsupported data format: #{error.message}"
end
end
def self.parse_last_updated(date_str)
return nil if date_str.blank?
begin
Date.strptime(date_str, '%Y-%m-%d')
rescue Date::Error
Rails.logger.error(
"[OwlsValueGuide] unexpected last_updated format: #{date_str.inspect}"
)
return nil
end
end
end

View file

@ -1,12 +1,12 @@
%li %li
= link_to view_or_edit_alt_style_url(alt_style) do = link_to view_or_edit_alt_style_url(alt_style) do
= image_tag alt_style.preview_image_url, class: "preview", loading: "lazy" = image_tag alt_style.preview_image_url, class: "preview", loading: "lazy"
.name= alt_style.full_name .name
%span= alt_style.series_name
%span= alt_style.pet_name
.info .info
%p %p
Added Added
= time_tag alt_style.created_at, = time_tag alt_style.created_at,
title: alt_style.created_at.to_formatted_s(:long_nst) do title: alt_style.created_at.to_formatted_s(:long_nst) do
= time_with_only_month_if_old alt_style.created_at = time_with_only_month_if_old alt_style.created_at
- if support_staff? && !alt_style.real_series_name?
%p ⚠️ Needs series name

View file

@ -13,29 +13,28 @@
= image_tag @alt_style.preview_image_url, class: "alt-style-preview" = image_tag @alt_style.preview_image_url, class: "alt-style-preview"
= support_form_with model: @alt_style do |f| = form_with model: @alt_style, class: "alt-style-form" do |f|
= f.errors - if @alt_style.errors.any?
%p
= f.fields do Could not save:
= f.field do %ul.errors
= f.label :real_series_name, "Series" - @alt_style.errors.each do |error|
= f.text_field :real_series_name, autofocus: !@alt_style.real_series_name?, %li= error.full_message
placeholder: AltStyle.placeholder_name %fieldset
= f.label :real_series_name, "Series"
= f.field do = f.text_field :real_series_name
= f.label :real_series_name, "Full name" = f.label :thumbnail_url, "Thumbnail"
= f.text_field :real_full_name, placeholder: @alt_style.fallback_full_name .thumbnail-field
- if @alt_style.thumbnail_url?
= f.field do = image_tag @alt_style.thumbnail_url
= f.label :thumbnail_url, "Thumbnail" = f.url_field :thumbnail_url
= f.thumbnail_input :thumbnail_url .actions
= f.actions do
= f.submit "Save changes" = f.submit "Save changes"
= f.go_to_next_field title: "If checked, takes you to the next unlabeled pet style, if any. Useful for labeling in bulk!" do %label{title: "If checked, takes you to the next unlabeled pet style, if any. Useful for labeling in bulk!"}
= f.go_to_next_check_box "unlabeled-style" = check_box_tag "next", "unlabeled-style",
checked: params[:next] == "unlabeled-style"
Then: Go to unlabeled style Then: Go to unlabeled style
- content_for :stylesheets do - content_for :stylesheets do
= stylesheet_link_tag "application/breadcrumbs", "application/support-form" = stylesheet_link_tag "application/breadcrumbs"
= page_stylesheet_link_tag "alt_styles/edit" = page_stylesheet_link_tag "alt_styles/edit"

View file

@ -22,16 +22,6 @@
[1]: https://www.neopets.com/mall/stylingstudio/ [1]: https://www.neopets.com/mall/stylingstudio/
- if support_staff?
%p
%strong 💡 Support summary:
✅ #{number_with_delimiter @counts[:labeled]} labeled
- if @unlabeled_style
+ ❓️
= link_to "#{number_with_delimiter @counts[:unlabeled]} unlabeled",
edit_alt_style_path(@unlabeled_style, next: "unlabeled-style")
\= #{number_with_delimiter @counts[:total]} total
= form_with url: alt_styles_path, method: :get, = form_with url: alt_styles_path, method: :get,
class: "rainbow-pool-filters" do |f| class: "rainbow-pool-filters" do |f|
%fieldset %fieldset
@ -44,12 +34,11 @@
selected: @species&.human_name, include_blank: "Species…" selected: @species&.human_name, include_blank: "Species…"
= f.submit "Go", name: nil = f.submit "Go", name: nil
- if @alt_styles.present? = will_paginate @alt_styles, class: "rainbow-pool-pagination"
= will_paginate @alt_styles, class: "rainbow-pool-pagination"
%ul.rainbow-pool-list= render @alt_styles %ul.rainbow-pool-list= render @alt_styles
= will_paginate @alt_styles, class: "rainbow-pool-pagination"
- else = will_paginate @alt_styles, class: "rainbow-pool-pagination"
%p.rainbow-pool-no-results We don't have any styles matching that search.
- content_for :stylesheets do - content_for :stylesheets do
= stylesheet_link_tag "application/breadcrumbs" = stylesheet_link_tag "application/breadcrumbs"

View file

@ -21,9 +21,7 @@
} }
- if swf_asset.canvas_movie? - if swf_asset.canvas_movie?
%iframe{src: swf_asset_path(swf_asset, playing: outfit_viewer_is_playing ? true : nil)} %iframe{src: swf_asset_path(swf_asset, playing: outfit_viewer_is_playing ? true : nil)}
- elsif preferred_image_format == :svg && swf_asset.svg_url? - elsif swf_asset.image_url.present?
= image_tag swf_asset.svg_url, alt: "", loading: "lazy"
- elsif swf_asset.image_url?
= image_tag swf_asset.image_url, alt: "", loading: "lazy" = image_tag swf_asset.image_url, alt: "", loading: "lazy"
- else - else
/ No movie or image available for SWF asset: #{swf_asset.url} / No movie or image available for SWF asset: #{swf_asset.url}

View file

@ -1,7 +0,0 @@
- if form.object.errors.any?
%section.errors
Could not save:
%ul
- form.object.errors.each do |error|
%li= error.full_message

View file

@ -1,4 +0,0 @@
= form.field("data-type": "radio", **options) do
%fieldset
%legend= legend
%ul= content

View file

@ -1,5 +0,0 @@
- url = form.object.send(method)
.thumbnail-input
- if url.present?
= image_tag url, alt: "Thumbnail"
= form.url_field method

View file

@ -31,14 +31,6 @@
= f.label :contact_neopets_connection_id = f.label :contact_neopets_connection_id
= f.collection_select :contact_neopets_connection_id, @user.neopets_connections, :id, :neopets_username, {include_blank: true}, 'data-new-text' => t('.neopets_username.new'), 'data-new-prompt' => t('.neopets_username.prompt') = f.collection_select :contact_neopets_connection_id, @user.neopets_connections, :id, :neopets_username, {include_blank: true}, 'data-new-text' => t('.neopets_username.new'), 'data-new-prompt' => t('.neopets_username.prompt')
= f.submit t('.neopets_username.submit') = f.submit t('.neopets_username.submit')
- if support_staff?
= link_to "✏️ #{t('.support')}", edit_user_path(@user)
- if support_staff? && @user.shadowbanned?
%p.warning
%strong 🕶️ Shadowbanned:
For most users, this page is hidden, but you can still see them because
you're Support staff.
- unless public_perspective? - unless public_perspective?
%noscript %noscript

View file

@ -17,17 +17,11 @@
%th= t(".table.headings.user.#{@type}") %th= t(".table.headings.user.#{@type}")
%th= t(".table.headings.lists") %th= t(".table.headings.lists")
%tbody %tbody
- prev_trade = nil
- sorted_vaguely_by_trade_activity(@trades).each do |trade| - sorted_vaguely_by_trade_activity(@trades).each do |trade|
%tr %tr
%td{
'data-is-same-as-prev': same_vague_trade_timestamp?(trade, prev_trade)
}
= vague_trade_timestamp trade
%td %td
= trade.user.name = vague_trade_timestamp trade.user.last_trade_activity_at
- if support_staff? && trade.user.shadowbanned? %td= trade.user.name
%abbr{title: "Shadowbanned (Hidden from most viewers; you can see because you're Support staff"} 🕶️ SBan
%td %td
- if trade.lists.present? - if trade.lists.present?
%ul.trade-list-names %ul.trade-list-names
@ -38,7 +32,6 @@
= link_to t(".table.not_in_a_list.#{@type}"), user_closet_hangers_path(trade.user, = link_to t(".table.not_in_a_list.#{@type}"), user_closet_hangers_path(trade.user,
anchor: "closet-hangers-group-#{@type == :offering}"), anchor: "closet-hangers-group-#{@type == :offering}"),
class: "not-in-a-list" class: "not-in-a-list"
- prev_trade = trade
- else - else
%p= t(".no_trades_yet") %p= t(".no_trades_yet")

View file

@ -33,13 +33,9 @@
= link_to t('items.show.resources.jn_items'), jn_items_url_for(item) = link_to t('items.show.resources.jn_items'), jn_items_url_for(item)
= link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item) = link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item)
- if item.nc_trade_value - if item.nc_trade_value
= link_to lebron_url_for(item), = link_to t('items.show.resources.owls', value: item.nc_trade_value.value_text),
title: nc_trade_value_updated_at_text(item.nc_trade_value) do "https://www.neopets.com/~owls",
= t 'items.show.resources.lebron_value', title: nc_trade_value_updated_at_text(item.nc_trade_value)
value: nc_trade_value_estimate_text(item.nc_trade_value)
- elsif item.nc?
= link_to lebron_url_for(item) do
= t 'items.show.resources.lebron'
- unless item.nc? - unless item.nc?
= link_to t('items.show.resources.shop_wizard'), shop_wizard_url_for(item) = link_to t('items.show.resources.shop_wizard'), shop_wizard_url_for(item)
= link_to t('items.show.resources.trading_post'), trading_post_url_for(item) = link_to t('items.show.resources.trading_post'), trading_post_url_for(item)
@ -50,8 +46,6 @@
= link_to t('items.show.closet_hangers.button'), = link_to t('items.show.closet_hangers.button'),
user_closet_hangers_path(current_user), user_closet_hangers_path(current_user),
class: 'user-lists-form-opener' class: 'user-lists-form-opener'
- if support_staff?
= link_to "Edit", edit_item_path(item)
- if user_signed_in? - if user_signed_in?
= form_tag update_quantities_user_item_closet_hangers_path(user_id: current_user, item_id: item), method: :put, class: 'user-lists-form', hidden: item_header_user_lists_form_state != "open" do = form_tag update_quantities_user_item_closet_hangers_path(user_id: current_user, item_id: item), method: :put, class: 'user-lists-form', hidden: item_header_user_lists_form_state != "open" do

View file

@ -1,58 +0,0 @@
- title "Editing \"#{@item.name}\""
- use_responsive_design
%h1#title Editing "#{@item.name}"
:markdown
Heads up: the modeling process controls some of these fields by default! If
you change something, but it doesn't match what we're seeing on Neopets.com,
it will probably be reverted automatically when someone models it.
= support_form_with model: @item do |f|
= f.errors
= f.fields do
= f.field do
= f.label :name
= f.text_field :name
= f.field do
= f.label :thumbnail_url, "Thumbnail"
= f.thumbnail_input :thumbnail_url
= f.field do
= f.label :description
= f.text_field :description
= f.radio_fieldset "Item kind" do
= f.radio_field title: "NC items generally have a rarity value of 500.\nPaintbrush items generally contain a special message in the description." do
= f.radio_button :is_manually_nc, false
Automatic: Based on rarity and description
= f.radio_field title: "Use this when Neopets releases an NC item, but labels the rarity as something other than 500, usually by mistake." do
= f.radio_button :is_manually_nc, true
Manually NC: From the NC Mall, but not r500
= f.radio_fieldset "Modeling status" do
= f.radio_field title: "If we fit two or more species of a standard color, assume we also fit the other standard-color pets that were released at the time.\nRepeat for special colors like Baby and Maraquan." do
= f.radio_button :modeling_status_hint, ""
Automatic: Fits 2+ species &rarr; Should fit all
= f.radio_field title: "Use this when e.g. there simply is no Acara version of the item." do
= f.radio_button :modeling_status_hint, "done"
Done: Neopets.com is missing some models
= f.radio_field title: "Use this when e.g. this fits the Blue Vandagyre even though it's a Maraquan item.\nBehaves identically to Done, but helps us remember why we did this!" do
= f.radio_button :modeling_status_hint, "glitchy"
Glitchy: Neopets.com has <em>too many</em> models
= f.radio_fieldset "Body fit" do
= f.radio_field title: "When an asset in a zone like Background is modeled, assume it fits all pets the same, and assign it body ID \#0.\nOtherwise, assume it fits only the kind of pet it was modeled on." do
= f.radio_button :explicitly_body_specific, false
Automatic: Some zones fit all species
= f.radio_field title: "Use this when an item uses a generally-universal zone like Static, but is body-specific regardless. \"Encased in Ice\" is one example.\nThis prevents these uncommon items from breaking every time they're modeled." do
= f.radio_button :explicitly_body_specific, true
Body-specific: Fits all species differently
= f.actions do
= f.submit "Save changes"
- content_for :stylesheets do
= page_stylesheet_link_tag "application/support-form"

View file

@ -64,17 +64,17 @@
.item-zones-info .item-zones-info
%section %section
%h3 Occupies %h3 Occupies
- if @appearances_by_occupied_zone_label.present? - if @appearances_by_occupied_zone.present?
%ul %ul
- @appearances_by_occupied_zone_label.each do |label, appearances| - @appearances_by_occupied_zone.each do |zone, appearances_in_zone|
%li< %li<
= label = zone.label
- if item_zone_partial_fit? appearances, @all_appearances - if item_zone_partial_fit? appearances_in_zone, @all_appearances
= " " = " "
%span.zone-species-info{ %span.zone-species-info{
title: item_zone_species_list(appearances) title: item_zone_species_list(appearances_in_zone)
}< }<
(#{appearances.size} species) (#{appearances_in_zone.size} species)
- else - else
%span.no-zones (None) %span.no-zones (None)

View file

@ -92,7 +92,7 @@
title: "This recipe is NOT currently scheduled to be removed " + title: "This recipe is NOT currently scheduled to be removed " +
"from Dyeworks. It might not stay forever, but it's also " + "from Dyeworks. It might not stay forever, but it's also " +
"not part of a known limited-time event, like most " + "not part of a known limited-time event, like most " +
"Dyeworks items are. (Thanks Lebron team!)" "Dyeworks items are. (Thanks Owls team!)"
} }
(Always available) (Always available)
- elsif item.dyeworks_limited_final_date.present? - elsif item.dyeworks_limited_final_date.present?
@ -100,14 +100,14 @@
title: "This recipe is part of a limited-time Dyeworks " + title: "This recipe is part of a limited-time Dyeworks " +
"event. The last day you can dye this is " + "event. The last day you can dye this is " +
"#{item.dyeworks_limited_final_date.to_fs(:long)}. " + "#{item.dyeworks_limited_final_date.to_fs(:long)}. " +
"(Thanks Lebron team!)" "(Thanks Owls team!)"
} }
(Limited-time: #{item.dyeworks_limited_final_date.to_fs(:month_and_day)}) (Limited-time: #{item.dyeworks_limited_final_date.to_fs(:month_and_day)})
- elsif item.dyeworks_limited? - elsif item.dyeworks_limited?
%span.dyeworks-timeframe{ %span.dyeworks-timeframe{
title: "This recipe is part of a limited-time Dyeworks " + title: "This recipe is part of a limited-time Dyeworks " +
"event, and is scheduled to be removed from the NC Mall " + "event, and is scheduled to be removed from the NC Mall " +
"soon. (Thanks Lebron team!)" "soon. (Thanks Owls team!)"
} }
(Limited-time) (Limited-time)
@ -169,7 +169,7 @@
alt: "Item thumbnail for #{color.pb_item_name}" alt: "Item thumbnail for #{color.pb_item_name}"
- elsif color - elsif color
= image_tag pet_type_image_url(@pb_color_pet_types[color], size: :face), = image_tag pet_type_image_url(@pb_color_pet_types[color], size: :face),
srcset: ["#{pet_type_image_url(@pb_color_pet_types[color], size: :face_3x)} 2x"], srcset: ["#{pet_type_image_url(@pb_color_pet_types[color], size: :face_2x)} 2x"],
alt: @pb_color_pet_types[color].human_name alt: @pb_color_pet_types[color].human_name
- else - else
= image_tag "https://images.neopets.com/items/starter_red_pb.gif", = image_tag "https://images.neopets.com/items/starter_red_pb.gif",
@ -210,9 +210,11 @@
- if @items[:other_nc].any?(&:nc_trade_value) - if @items[:other_nc].any?(&:nc_trade_value)
:markdown :markdown
The "Lebron NC Values" team keep track of the details about how to get The [Owls Value Guide][owls] often has more details about how to get
these items, and how much they're usually worth in the NC Trading these items, and how much they're usually worth in the NC Trading
community. We've loaded their info here for you, too! Thanks, Lebron team! community. We've loaded their info here for you, too! Thanks, Owls team!
[owls]: https://www.neopets.com/~owls
%table.item-list{"data-complexity": complexity_for(@items[:other_nc])} %table.item-list{"data-complexity": complexity_for(@items[:other_nc])}
%thead %thead
@ -225,17 +227,17 @@
- content_for :subtitle, flush: true do - content_for :subtitle, flush: true do
- if item.nc_trade_value.present? - if item.nc_trade_value.present?
- if nc_trade_value_is_estimate(item.nc_trade_value) - if nc_trade_value_is_estimate(item.nc_trade_value)
= link_to 'https://www.neopets.com/~lebron', target: '_blank', = link_to "https://www.neopets.com/~owls",
class: "nc-trade-guide-info-link", class: "owls-info-link", target: "_blank",
title: 'Lebron keeps track of approximate "capsule" values of NC items for trading. Items with similar values can often be traded for one another. This is an estimate, not a rule!' do title: 'Owls keeps track of approximate "capsule" values of NC items for trading. Items with similar values can often be traded for one another. This is an estimate, not a rule!' do
%span.nc-trade-guide-info-label [Lebron] %span.owls-info-label [Owls]
Estimated value: Estimated value:
= nc_trade_value_estimate_text(item.nc_trade_value) = nc_trade_value_estimate_text(item.nc_trade_value)
- else - else
= link_to 'https://www.neopets.com/~lebron', target: '_blank', = link_to "https://www.neopets.com/~owls",
class: "nc-trade-guide-info-link", class: "owls-info-link", target: "_blank",
title: "Lebron keeps track of how to get certain special items, even when there isn't a clear NC trade estimate." do title: "Owls keeps track of how to get certain special items, even when there isn't a clear NC trade estimate." do
%span.nc-trade-guide-info-label [Lebron] %span.owls-info-label [Owls]
Trade info: Trade info:
#{item.nc_trade_value.value_text} #{item.nc_trade_value.value_text}

View file

@ -4,19 +4,20 @@
%p#pet-not-found.alert= t 'pets.load.not_found' %p#pet-not-found.alert= t 'pets.load.not_found'
- hide_after Date.new(2024, 12, 8) do - if show_announcement?
%section.announcement %section.announcement
= image_tag "about/announcement.png", width: 70, height: 70, = image_tag "about/announcement.png", width: 70, height: 70,
srcset: {"about/announcement@2x.png": "2x"} srcset: {"about/announcement@2x.png": "2x"}
.content .content
%p %p
%strong Oh wow, it's busy this time of year! %strong
We've temporarily moved to a bigger server, to help us handle the extra 🎃
load. Hopefully this keeps us running smooth! = link_to "New pet styles are out today!", alt_styles_path
If you've seen one we don't have yet, please model it by entering the
pet's name in the box below. Thank you!!
%p %p
Happy holidays, everyone! Here's hoping you, and your families, and your By the way, we had a bug where modeling new styles wasn't working for a
precious pets—both online and off—stay happy and healthy for the year little while. Fixed now! 🤞
to come 💜
#outfit-forms #outfit-forms
#pet-preview #pet-preview

View file

@ -1,42 +0,0 @@
= content_tag "support-outfit-viewer", **html_options do
%div
.outfit-viewer-area
%magic-magnifier{"data-format": "svg"}
= outfit_viewer outfit, preferred_image_format: :svg
%magic-magnifier{"data-format": "png"}
= outfit_viewer outfit, preferred_image_format: :png
= form_with method: :get, class: "outfit-viewer-controls" do |f|
%fieldset
%legend Format
%label
= f.radio_button "preferred_image_format", "svg",
checked: true
SVG
%label
= f.radio_button "preferred_image_format", "png"
PNG
%table
%thead
%tr
%th{scope: "col"} DTI ID
%th{scope: "col"} Zone
%th{scope: "col"} Links
%tbody
- outfit.visible_layers.each do |swf_asset|
%tr
%th{scope: "row", "data-field": "id"}
= swf_asset.id
%td
= swf_asset.zone.label
(##{swf_asset.zone.id})
%td{"data-field": "links"}
%ul
- if swf_asset.image_url?
%li= link_to "PNG", swf_asset.image_url, target: "_blank"
- if swf_asset.svg_url?
%li= link_to "SVG", swf_asset.svg_url, target: "_blank"
%li= link_to "SWF", swf_asset.url, target: "_blank"
- if swf_asset.manifest_url?
%li= link_to "Manifest", swf_asset.manifest_url, target: "_blank"

View file

@ -5,54 +5,44 @@
%li %li
= link_to "Rainbow Pool", pet_types_path = link_to "Rainbow Pool", pet_types_path
%li %li
= link_to @pet_type.possibly_new_color.human_name, = link_to @pet_type.color.human_name,
pet_types_path(color: @pet_type.possibly_new_color.human_name) pet_types_path(color: @pet_type.color.human_name)
%li{"data-relation-to-prev": "sibling"} %li{"data-relation-to-prev": "sibling"}
= link_to @pet_type.possibly_new_species.human_name, = link_to @pet_type.species.human_name,
pet_types_path(species: @pet_type.possibly_new_species.human_name) pet_types_path(species: @pet_type.species.human_name)
%li %li
= link_to "Appearances", @pet_type = link_to "Appearances", @pet_type
%li %li
\##{@pet_state.id} \##{@pet_state.id}
= support_outfit_viewer pet_state: @pet_state = outfit_viewer pet_state: @pet_state
= support_form_with model: [@pet_type, @pet_state] do |f| = form_with model: [@pet_type, @pet_state] do |f|
= f.errors - if @pet_state.errors.any?
%p
= f.fields do Could not save:
= f.radio_grid_fieldset "Pose" do %ul.errors
- pose_options.each do |pose| - @pet_state.errors.each do |error|
= f.radio_field do %li= error.full_message
= f.radio_button :pose, pose %dl
= pose_name(pose) %dt Pose
- if @reference_pet_type %dd
= link_to @reference_pet_type, target: "_blank", class: "reference-link" do %ul.pose-options
= pet_type_image @reference_pet_type, :happy, :face - pose_options.each do |pose|
%span Reference: #{@reference_pet_type.human_name} %li
= external_link_icon %label
= f.radio_button :pose, pose
= f.field do = pose_name pose
= f.label :glitched, "Glitched?" %dt Glitched?
%dd
= f.select :glitched, [["✅ Not marked as Glitched", false], = f.select :glitched, [["✅ Not marked as Glitched", false],
["👾 Yes, it's bad news bonko'd", true]] ["👾 Yes, it's bad news bonko'd", true]]
= f.submit "Save"
= f.actions do
= f.submit "Save changes"
= f.go_to_next_field after: @pet_state.id,
title: "If checked, takes you to the first unlabeled appearance in the database, if any. Useful for labeling in bulk!" do
= f.go_to_next_check_box "unlabeled-appearance"
Then: Go to next unlabeled appearance
- content_for :stylesheets do - content_for :stylesheets do
= stylesheet_link_tag "application/breadcrumbs" = stylesheet_link_tag "application/breadcrumbs"
= stylesheet_link_tag "application/magic-magnifier"
= stylesheet_link_tag "application/outfit-viewer" = stylesheet_link_tag "application/outfit-viewer"
= stylesheet_link_tag "application/support-form"
= stylesheet_link_tag "pet_states/support-outfit-viewer"
= page_stylesheet_link_tag "pet_states/edit" = page_stylesheet_link_tag "pet_states/edit"
- content_for :javascripts do - content_for :javascripts do
= javascript_include_tag "magic-magnifier"
= javascript_include_tag "outfit-viewer" = javascript_include_tag "outfit-viewer"
= javascript_include_tag "pet_states/support-outfit-viewer"

View file

@ -10,18 +10,6 @@
[1]: #{alt_styles_path} [1]: #{alt_styles_path}
- if support_staff?
%p
%strong 💡 Support summary:
✅ #{number_with_delimiter @counts[:usable]} usable
+ 👾 #{number_with_delimiter @counts[:glitched]} glitched
- if @unlabeled_appearance
+ ❓️
= link_to "#{number_with_delimiter @counts[:needs_labeling]} unknown",
edit_pet_type_pet_state_path(@unlabeled_appearance.pet_type,
@unlabeled_appearance, next: "unlabeled-appearance")
\= #{number_with_delimiter @counts[:total]} total
= form_with method: :get, class: "rainbow-pool-filters" do |form| = form_with method: :get, class: "rainbow-pool-filters" do |form|
%fieldset %fieldset
%legend Filter by: %legend Filter by:
@ -34,7 +22,7 @@
%ui.rainbow-pool-list= render @pet_types %ui.rainbow-pool-list= render @pet_types
= will_paginate @pet_types, class: "rainbow-pool-pagination" = will_paginate @pet_types, class: "rainbow-pool-pagination"
- else - else
%p.rainbow-pool-no-results We don't have any pets matching that search. %p.rainbow-pool-no-results No matching pets found!
- content_for :stylesheets do - content_for :stylesheets do
= stylesheet_link_tag "application/rainbow-pool" = stylesheet_link_tag "application/rainbow-pool"

View file

@ -5,11 +5,11 @@
%li %li
= link_to "Rainbow Pool", pet_types_path = link_to "Rainbow Pool", pet_types_path
%li %li
= link_to @pet_type.possibly_new_color.human_name, = link_to @pet_type.color.human_name,
pet_types_path(color: @pet_type.possibly_new_color.human_name) pet_types_path(color: @pet_type.color.human_name)
%li{"data-relation-to-prev": "sibling"} %li{"data-relation-to-prev": "sibling"}
= link_to @pet_type.possibly_new_species.human_name, = link_to @pet_type.species.human_name,
pet_types_path(species: @pet_type.possibly_new_species.human_name) pet_types_path(species: @pet_type.species.human_name)
%li %li
Appearances Appearances

View file

@ -1,40 +0,0 @@
- title @user.name
- use_responsive_design
%ol.breadcrumbs
%li Users
%li= link_to @user.name, user_closet_hangers_path(@user)
= support_form_with model: @user do |f|
= f.errors
= f.fields do
= f.field do
= f.label :name
= f.text_field :name
= f.radio_fieldset "Item list visibility" do
= f.radio_field do
= f.radio_button :shadowbanned, false
%strong 👁️ Visible:
Everyone can see page and trades
= f.radio_field do
= f.radio_button :shadowbanned, true
%strong 🕶️ Shadowbanned:
Page and trades hidden from other users/IPs
= f.radio_fieldset "Account role" do
= f.radio_field do
= f.radio_button :support_staff, false
%strong 👤 User:
Can manage their own data
= f.radio_field do
= f.radio_button :support_staff, true
%strong 💖 Support:
Can manage other users' data and customization data
= f.actions do
= f.submit "Save changes"
- content_for :stylesheets do
= stylesheet_link_tag "application/breadcrumbs", "application/support-form"

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,3 +0,0 @@
#!/usr/bin/env bash
# Rollback to a previous version in production.
cd $(dirname $0)/../deploy && ansible-playbook rollback.yml --extra-vars="new_app_version=$1"

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

@ -1,6 +1,7 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
require "fileutils" require "fileutils"
# path to your application root.
APP_ROOT = File.expand_path("..", __dir__) APP_ROOT = File.expand_path("..", __dir__)
def system!(*args) def system!(*args)
@ -12,7 +13,8 @@ FileUtils.chdir APP_ROOT do
# This script is idempotent, so that you can run it at any time and get an expectable outcome. # This script is idempotent, so that you can run it at any time and get an expectable outcome.
# Add necessary setup steps to this file. # Add necessary setup steps to this file.
puts "== Installing Ruby dependencies ==" puts "== Installing dependencies =="
system! "gem install bundler --conservative"
system("bundle check") || system!("bundle install") system("bundle check") || system!("bundle install")
# puts "\n== Copying sample files ==" # puts "\n== Copying sample files =="
@ -23,25 +25,9 @@ FileUtils.chdir APP_ROOT do
puts "\n== Preparing database ==" puts "\n== Preparing database =="
system! "bin/rails db:prepare" 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"
puts "\n== Installing Yarn dependencies =="
system! "corepack enable"
system! "corepack install"
system! "yarn install"
puts "\n== Building development JS files =="
system! "yarn build:dev"
puts "\n== Removing old logs and tempfiles ==" puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear" system! "bin/rails log:clear tmp:clear"
unless ARGV.include?("--skip-server") puts "\n== Restarting application server =="
puts "\n== Starting development server ==" system! "bin/rails restart"
STDOUT.flush # flush the output before exec(2) so that it displays
exec "bin/dev"
end
end end

View file

@ -5,18 +5,25 @@ require "rails"
# We disable some components we don't use, to: omit their routes, be confident # We disable some components we don't use, to: omit their routes, be confident
# that there's not e.g. surprise storage happening on the machine, and keep the # that there's not e.g. surprise storage happening on the machine, and keep the
# app footprint smaller. # app footprint smaller.
#
# require "active_model/railtie" # Disabled:
# require "active_job/railtie" # - active_storage/engine
require "active_record/railtie" # - active_job/railtie
# require "active_storage/engine" # - action_cable/engine
require "action_controller/railtie" # - action_mailbox/engine
require "action_mailer/railtie" # - action_text/engine
# require "action_mailbox/engine" %w(
# require "action_text/engine" active_record/railtie
require "action_view/railtie" action_controller/railtie
# require "action_cable/engine" action_view/railtie
require "rails/test_unit/railtie" action_mailer/railtie
rails/test_unit/railtie
).each do |railtie|
begin
require railtie
rescue LoadError
end
end
# Require the gems listed in Gemfile, including any gems # Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production. # you've limited to :test, :development, or :production.
@ -25,12 +32,12 @@ Bundler.require(*Rails.groups)
module OpenneoImpressItems module OpenneoImpressItems
class Application < Rails::Application class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version. # Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.1 config.load_defaults 7.1
# Please, add to the `ignore` list any other `lib` subdirectories that do # 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. # not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example. # Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks]) config.autoload_lib(ignore: %w(assets tasks))
# Configuration for the application, engines, and railties goes here. # Configuration for the application, engines, and railties goes here.
# #
@ -64,10 +71,7 @@ module OpenneoImpressItems
# symbols? So I can't provide anything helpful like a URL, email address, # symbols? So I can't provide anything helpful like a URL, email address,
# version number, etc. So let's only send this to Neopets systems, where it # version number, etc. So let's only send this to Neopets systems, where it
# should hopefully be clear who we are from context! # should hopefully be clear who we are from context!
# config.user_agent_for_neopets = "Dress to Impress"
# NOTE: To be able to access Neopets.com, the User-Agent string must contain
# a slash character.
config.user_agent_for_neopets = "Dress to Impress (https://impress.openneo.net)"
# Use the usual Neopets.com, unless we have an override. (At times, we've # Use the usual Neopets.com, unless we have an override. (At times, we've
# used this in collaboration with TNT to address the server directly, # used this in collaboration with TNT to address the server directly,

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