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
ARG RUBY_VERSION=3.4.5
FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION
FROM mcr.microsoft.com/devcontainers/ruby:1-3.1-bullseye
# 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 config options, see the README at: https://github.com/devcontainers/templates/tree/main/src/ruby
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/ruby-rails-postgres
{
"name": "openneo_impress_items",
"dockerComposeFile": "compose.yaml",
"service": "rails-app",
"name": "Dress to Impress",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"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": {
"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}"
},
// "features": {},
// 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],
// 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.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://containers.dev/implementors/json_reference/#remoteUser.
// "remoteUser": "root",
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "bash .devcontainer/setup-ssh-config.sh && bin/setup --skip-server"
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View file

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

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.*
/spec/examples.txt
/.yardoc
/app/assets/builds/*
!/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'
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.
gem 'falcon', '~> 0.48.0'
@ -18,6 +18,7 @@ gem 'sprockets', '~> 4.2'
gem 'haml', '~> 6.1', '>= 6.1.1'
gem 'sass-rails', '~> 6.0'
gem 'terser', '~> 1.1', '>= 1.1.17'
gem 'react-rails', '~> 2.7', '>= 2.7.1'
gem 'jsbundling-rails', '~> 1.3'
gem 'turbo-rails', '~> 2.0'
@ -32,7 +33,7 @@ gem "omniauth_openid_connect", "~> 0.7.1"
gem 'will_paginate', '~> 4.0'
# 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'
# 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.
# 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', path: 'vendor/gems/RocketAMF-1.0.0'
gem 'RocketAMF', :git => 'https://github.com/rubyamf/rocketamf.git'
# For preventing too many modeling attempts.
gem 'rack-attack', '~> 6.7'
@ -53,22 +53,20 @@ gem 'rack-attack', '~> 6.7'
# For testing emails in development.
gem 'letter_opener', '~> 1.8', '>= 1.8.1', group: :development
# For parallel API calls.
gem 'parallel', '~> 1.23'
# For miscellaneous HTTP requests.
gem "httparty", "~> 0.22.0"
gem "addressable", "~> 2.8"
# For advanced batching of many HTTP requests.
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
# For image processing (outfit PNG rendering).
gem "ruby-vips", "~> 2.2"
# For debugging.
group :development do
gem 'debug', '~> 1.9.2'
gem 'web-console', '~> 4.2'
end
gem 'web-console', '~> 4.2', group: :development
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '~> 1.16', require: false
@ -86,15 +84,10 @@ gem "sentry-rails", "~> 5.12"
gem "shell", "~> 0.8.1"
# For workspace autocomplete.
group :development do
gem "solargraph", "~> 0.50.0"
gem "solargraph-rails", "~> 1.1"
end
gem "solargraph", "~> 0.50.0", group: :development
gem "solargraph-rails", "~> 1.1", group: :development
# For automated tests.
group :development, :test do
gem "rspec-rails", "~> 7.0"
end
group :test do
gem "webmock", "~> 3.24"
end

View file

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

161
README.md
View file

@ -2,163 +2,6 @@
# 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
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)**
There'll be more to say about it here soon :3

View file

@ -754,11 +754,6 @@
contactField.val(connection.id);
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 {

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.
let hasLoggedRenderError = false;
////////////////////////////////////////////////////
//////// Loading the library and its assets ////////
////////////////////////////////////////////////////
function loadImage(src) {
const image = new Image();
image.crossOrigin = "anonymous";
@ -68,8 +64,8 @@ async function getLibrary() {
// One more loading step as part of loading this library is loading the
// images it uses for sprites.
//
// NOTE: We also read these from the manifest, and include them in the
// document as preload meta tags, to get them moving faster.
// TODO: I guess the manifest has these too, so we could put them in preload
// meta tags to get them here faster?
const librarySrcDir = libraryUrl.split("/").slice(0, -1).join("/");
const manifestImages = new Map(
library.properties.manifest.map(({ id, src }) => [
@ -100,10 +96,6 @@ async function getLibrary() {
return library;
}
/////////////////////////////////////
//////// Rendering the movie ////////
/////////////////////////////////////
function buildMovieClip(library) {
let constructorName;
try {
@ -159,22 +151,6 @@ function updateCanvasDimensions() {
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() {
// Load the movie's library (from the JS file already run), and use it to
// 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
* there are any animated areas.
@ -340,6 +312,18 @@ function sendMessage(message) {
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 }) => {
// 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
@ -355,10 +339,6 @@ window.addEventListener("message", ({ data }) => {
}
});
/////////////////////////////////
//// The actual entry point! ////
/////////////////////////////////
startMovie()
.then(() => {
sendStatus();

View file

@ -2,3 +2,54 @@
width: 300px
height: 300px
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
.name
text-wrap: balance
// De-emphasize Prismatic styles, in browsers that support it.
.rainbow-pool-filters
select[name="series"]
option[value*=": "]
color: $soft-text-color
font-style: italic
.name span
display: inline-block

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

View file

@ -18,16 +18,6 @@
overflow: hidden
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
list-style: none

View file

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

View file

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

View file

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

View file

@ -1,15 +1,25 @@
support-outfit-viewer
margin-block: 1em
@import "../partials/clean/constants"
.fields li[data-type=radio-grid]
--num-columns: 3
outfit-viewer
margin: 0 auto
.reference-link
display: flex
align-items: center
gap: .5em
padding-inline: .5em
.pose-options
list-style-type: none
display: grid
grid-template-columns: 1fr 1fr 1fr
gap: .25em
img
height: 2em
width: auto
label
display: flex
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]
def index
@all_series_names = AltStyle.all_series_names
@all_color_names = AltStyle.all_supported_colors.map(&:human_name).sort
@all_species_names = AltStyle.all_supported_species.map(&:human_name).sort
@all_alt_styles = AltStyle.includes(:species, :color)
@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]
@color = find_color
@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.merge!(@color.alt_styles) if @color
@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|
format.html {
@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.html { render }
format.json {
@alt_styles = @alt_styles.includes(swf_assets: [:zone]).by_name_grouped
render json: @alt_styles.as_json(
only: [:id, :species_id, :color_id, :body_id, :thumbnail_url],
render json: @alt_styles.includes(swf_assets: [:zone]).as_json(
only: [:id, :species_id, :color_id, :body_id, :series_name,
:adjective_name, :thumbnail_url],
include: {
swf_assets: {
only: [:id, :body_id],
@ -47,7 +39,7 @@ class AltStylesController < ApplicationController
methods: [:urls, :known_glitches],
}
},
methods: [:series_main_name, :adjective_name],
methods: [:series_name, :adjective_name, :thumbnail_url],
)
}
end
@ -71,8 +63,7 @@ class AltStylesController < ApplicationController
protected
def alt_style_params
params.require(:alt_style).
permit(:real_series_name, :real_full_name, :thumbnail_url)
params.require(:alt_style).permit(:real_series_name, :thumbnail_url)
end
def find_color

View file

@ -4,7 +4,7 @@ require 'async/container'
class ApplicationController < ActionController::Base
protect_from_forgery
helper_method :current_user, :support_staff?, :user_signed_in?
helper_method :current_user, :user_signed_in?
before_action :set_locale
@ -111,12 +111,10 @@ class ApplicationController < ActionController::Base
return_to || root_path
end
def support_staff?
current_user&.support_staff?
end
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

View file

@ -218,7 +218,7 @@ class ClosetHangersController < ApplicationController
def enforce_shadowban
# If this user is shadowbanned, and this *doesn't* seem to be a request
# 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
end
end

View file

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

View file

@ -1,6 +1,5 @@
class ItemsController < ApplicationController
before_action :set_query
before_action :support_staff_only, except: [:index, :show, :sources]
rescue_from Item::Search::Error, :with => :search_error
def index
@ -80,10 +79,7 @@ class ItemsController < ApplicationController
respond_to do |format|
format.html do
@trades = @item.visible_trades(
user: current_user,
remote_ip: request.remote_ip
)
@trades = @item.closet_hangers.trading.user_is_active.to_trades
@contributors_with_counts = @item.contributors_with_counts
@ -101,23 +97,14 @@ class ItemsController < ApplicationController
@preview_error = validate_preview
@all_appearances = @item.appearances
@appearances_by_occupied_zone_label =
@item.appearances_by_occupied_zone_label.sort_by { |l, a| l }
@appearances_by_occupied_zone = @item.appearances_by_occupied_zone.
sort_by { |z, a| z.label }
@selected_item_appearance = @preview_outfit.item_appearances.first
@preview_pet_type_options = PetType.where(color: @preview_outfit.color).
includes(:species).merge(Species.alphabetical)
end
format.json do
render json: @item.as_json(
include_trade_counts: true,
include_nc_trade_value: true,
current_user: current_user,
remote_ip: request.remote_ip
)
end
format.gif do
expires_in 1.month
redirect_to @item.thumbnail_url, allow_other_host: true
@ -125,21 +112,6 @@ class ItemsController < ApplicationController
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
# Load all the items, then group them by source.
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
# 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])
# Start loading the NC trade values for the non-Mall NC items.
@ -192,15 +164,6 @@ class ItemsController < ApplicationController
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)
current_user.assign_closeted_to_items!(items) if user_signed_in?
end

View file

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

View file

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

View file

@ -1,7 +1,6 @@
class PetStatesController < ApplicationController
before_action :support_staff_only
before_action :find_pet_state
before_action :preload_assets
before_action :support_staff_only
def edit
end
@ -9,7 +8,7 @@ class PetStatesController < ApplicationController
def update
if @pet_state.update(pet_state_params)
flash[:notice] = "Pet appearance \##{@pet_state.id} successfully saved!"
redirect_to destination_after_save
redirect_to @pet_type
else
render action: :edit, status: :bad_request
end
@ -18,39 +17,11 @@ class PetStatesController < ApplicationController
protected
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])
@reference_pet_type = @pet_type.reference
end
def preload_assets
SwfAsset.preload_manifests @pet_state.swf_assets
end
def pet_state_params
params.require(:pet_state).permit(:pose, :glitched)
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

View file

@ -35,16 +35,6 @@ class PetTypesController < ApplicationController
if @selected_species && @selected_color && @pet_types.size == 1
redirect_to @pet_types.first
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 {
@ -80,7 +70,9 @@ class PetTypesController < ApplicationController
color_id: params[:color_id],
)
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
raise "expected params: species_id and color_id, or name"
end

View file

@ -1,10 +1,12 @@
class PetsController < ApplicationController
rescue_from Neopets::CustomPets::PetNotFound, with: :pet_not_found
rescue_from Neopets::CustomPets::DownloadError, with: :pet_download_error
rescue_from Pet::ModelingDisabled, with: :modeling_disabled
rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format
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]
@pet = Pet.load(params[:name])
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
# our nginx reverse proxy (and probably some clients) handle it. (For
# 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
def src_list(*urls, origins: [])
clean_urls = urls.
def src_list(*urls, hosts: [])
urls.
# Ignore any `nil`s that might arise
filter(&:present?).
# Parse the URL.
map { |url| Addressable::URI.parse(url) }.
# Remove query strings from URLs (they're invalid in CSPs)
each { |url| url.query = nil }.
# For the given `origins`, remove all their specific URLs, because
# we'll just include the entire origin anyway.
reject { |url| origins.include?(url.origin) }.
# 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
map { |url| url.sub(/\?.*\z/, "") }.
# For the given `hosts`, remove all their specific URLs, and just list
# the host itself.
reject { |url| hosts.any? { |h| url.start_with? h } } + hosts
end
end

View file

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

View file

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

View file

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

View file

@ -141,13 +141,6 @@ module ItemsHelper
def auction_genie_url_for(item)
AUCTION_GENIE_URL_TEMPLATE.expand(auctiongenie: item.name).to_s
end
LEBRON_URL_TEMPLATE = Addressable::Template.new(
"https://stylisher.club/search/{name}"
)
def lebron_url_for(item)
LEBRON_URL_TEMPLATE.expand(name: item.name).to_s
end
def format_contribution_count(count)
" (&times;#{count})".html_safe if count > 1
@ -158,7 +151,7 @@ module ItemsHelper
end
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
# 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, %Y")
"NC trade value—Last updated: #{date_str} (#{time_ago_str} ago)"
"Last updated: #{date_str} (#{time_ago_str} ago)"
end
NC_TRADE_VALUE_ESTIMATE_PATTERN = %r{
@ -191,7 +184,7 @@ module ItemsHelper
# nicely for our use case.
def nc_trade_value_estimate_text(nc_trade_value)
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:}
if single.present?
@ -199,7 +192,7 @@ module ItemsHelper
elsif low.present? && high.present?
"#{low}#{high} capsules"
else
nc_trade_value.value_text
nc_trade_value
end
end

View file

@ -1,4 +1,9 @@
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)
hidden_field_tag 'destination', value, :id => nil
end
@ -65,27 +70,11 @@ module OutfitsHelper
text_field_tag 'name', nil, options
end
def outfit_viewer(...)
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
)
def outfit_viewer(outfit=nil, pet_state: nil, **html_options)
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?
raise ArgumentError, "outfit viewer must have outfit or pet state"
end
{outfit:, preferred_image_format:, html_options:}
render partial: "outfit_viewer", locals: {outfit:, html_options:}
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 * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing";
import {
ChakraProvider,
Box,
@ -41,6 +43,8 @@ const globalStyles = theme.styles.global;
theme.styles.global = {};
export default function AppProvider({ children }) {
React.useEffect(() => setupLogging(), []);
return (
<BrowserRouter>
<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
* children (or, well, any element with the chakra-css-reset class). It also

View file

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

View file

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

View file

@ -283,10 +283,7 @@ const PosePickerButton = React.forwardRef(
const theme = useTheme();
const icon = altStyle != null ? twemojiSunglasses : getIcon(pose);
const label =
altStyle != null
? altStyle.seriesMainName.split(/\s+/)[0]
: getLabel(pose);
const label = altStyle != null ? altStyle.seriesName : getLabel(pose);
return (
<ClassNames>
@ -339,9 +336,9 @@ const PosePickerButton = React.forwardRef(
ref={ref}
>
<EmojiImage src={icon} alt="Style" />
<Box overflow="hidden" textOverflow="ellipsis" marginX=".5em">
{label}
</Box>
<Box width=".5em" />
{label}
<Box width=".5em" />
<ChevronDownIcon />
</Button>
)}
@ -726,13 +723,6 @@ function StyleOption({ altStyle, checked, onChange, inputRef }) {
checked={checked}
onChange={(e) => onChange(altStyle.id)}
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
alignItems="center"

View file

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

View file

@ -1,18 +1,10 @@
import React from "react";
import {
Alert,
AlertIcon,
Box,
Text,
useColorModeValue,
VisuallyHidden,
} from "@chakra-ui/react";
import { Box, Text, useColorModeValue, VisuallyHidden } from "@chakra-ui/react";
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
import PaginationToolbar from "../components/PaginationToolbar";
import { useSearchResults } from "./useSearchResults";
import { MajorErrorMessage } from "../util";
import { useAltStylesForSpecies } from "../loaders/alt-styles";
export const SEARCH_PER_PAGE = 30;
@ -169,7 +161,6 @@ function SearchResults({
size="sm"
/>
</Box>
<SearchNCStylesHint query={query} outfitState={outfitState} />
<ItemListContainer paddingX="4" paddingBottom="2">
{items.map((item, index) => (
<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
* JS comparison.

View file

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

View file

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

View file

@ -8,6 +8,7 @@ import {
useColorModeValue,
} from "@chakra-ui/react";
import loadableLibrary from "@loadable/component";
import * as Sentry from "@sentry/react";
import { WarningIcon } from "@chakra-ui/icons";
import { buildImpress2020Url } from "./impress-2020-config";
@ -413,17 +414,14 @@ export function loadable(load, options) {
}
/**
* logAndCapture will print an error to the console.
*
* 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.
* logAndCapture will print an error to the console, and send it to Sentry.
*
* This is useful when there's a graceful recovery path, but it's still a
* genuinely unexpected error worth logging.
*/
export function logAndCapture(e) {
console.error(e);
Sentry.captureException(e);
}
export function getGraphQLErrorMessage(error) {
@ -477,8 +475,8 @@ export function MajorErrorMessage({ error = null, variant = "unexpected" }) {
<Box gridArea="description" marginBottom="2">
{variant === "unexpected" && (
<>
There was an error displaying this page. If it keeps happening,
you can tell me more at{" "}
There was an error displaying this page. I'll get info about it
automatically, but you can tell me more at{" "}
<Link href="mailto:matchu@openneo.net" color="green.400">
matchu@openneo.net
</Link>
@ -525,29 +523,10 @@ export function MajorErrorMessage({ error = null, variant = "unexpected" }) {
export function TestErrorSender() {
React.useEffect(() => {
if (window.location.href.includes("send-test-error")) {
throw new Error("Test error for debugging <ErrorBoundary>s");
if (window.location.href.includes("send-test-error-for-sentry")) {
throw new Error("Test error for Sentry");
}
});
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
validates :body_id, presence: true
validates :full_name, presence: true, allow_nil: true
validates :series_name, presence: true, allow_nil: true
validates :thumbnail_url, presence: true
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) {
color = Color.find_by_name!(color_name)
species = Species.find_by_name!(species_name)
where(series_name:, color_id: color.id, species_id: species.id)
}
scope :by_creation_date, -> {
# HACK: Setting up named time zones in MySQL takes effort, so we assume
# 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
order("DATE(created_at) DESC")
}
scope :unlabeled, -> { where(series_name: nil) }
scope :newest, -> { order(created_at: :desc) }
@ -60,23 +32,41 @@ class AltStyle < ApplicationRecord
alias_method :name, :pet_name
def series_main_name
series_name.split(': ').last
# If the series_name hasn't yet been set manually by support staff, show the
# 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
def series_variant_name
series_name.split(': ').first
def real_series_name=(new_series_name)
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
# Returns the full name, with the species removed from the end (if present).
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
EMPTY_IMAGE_URL = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
def preview_image_url
# Use the image URL for the first asset. Or, fall back to an empty image.
swf_assets.first&.image_url || EMPTY_IMAGE_URL
swf_asset = swf_assets.first
return nil if swf_asset.nil?
swf_asset.image_url
end
# 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, ...)
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
# 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
@ -104,28 +103,6 @@ class AltStyle < ApplicationRecord
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!
def self.find_by_name(color_name, species_name)
color = Color.find_by_name(color_name)

View file

@ -1,35 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
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

View file

@ -156,7 +156,7 @@ class ClosetHanger < ApplicationRecord
#
# 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!
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.
# (I still recommend doing it at the call site too for clarity, though!)
all_trading_hangers = trading.to_a
@ -164,20 +164,17 @@ class ClosetHanger < ApplicationRecord
owned_hangers = all_trading_hangers.filter(&:owned?)
wanted_hangers = all_trading_hangers.filter(&:wanted?)
# Group first into offering vs seeking, then by user. Only include trades
# visible to the current user.
# Group first into offering vs seeking, then by user.
offering, seeking = [owned_hangers, wanted_hangers].map do |hangers|
hangers.group_by(&:user_id).
map { |user_id, user_hangers| Trade.new(user_id, user_hangers) }.
filter { |trade| trade.visible_to?(current_user, remote_ip) }
hangers.group_by(&:user_id).map do |user_id, user_hangers|
Trade.new(user_id, user_hangers)
end
end
{offering: offering, seeking: seeking}
end
Trade = Struct.new('Trade', :user_id, :hangers) do
delegate :visible_to?, to: :user
def user
# Take advantage of `includes(:user)` on the hangers, if applied.
hangers.first.user
@ -186,10 +183,6 @@ class ClosetHanger < ApplicationRecord
def lists
hangers.map(&:list).filter(&:present?)
end
def last_activity_at
user.last_trade_activity_at
end
end
protected

View file

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

View file

@ -1,3 +1,6 @@
require "async"
require "async/barrier"
class Item < ApplicationRecord
include PrettyParam
include Item::Dyeworks
@ -20,21 +23,13 @@ class Item < ApplicationRecord
has_many :dyeworks_variants, class_name: "Item",
inverse_of: :dyeworks_base_item
# We require a name field. A number of other fields must be *specified*: they
# can't be nil, to help ensure we aren't forgetting any fields when importing
# 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?
validates_presence_of :name, :description, :thumbnail_url, :rarity, :price,
:zones_restrict
attr_writer :current_body_id, :owned, :wanted
NCRarities = [0, 500]
PAINTBRUSH_SET_DESCRIPTION = 'This item is part of a deluxe paint brush set'
PAINTBRUSH_SET_DESCRIPTION = 'This item is part of a deluxe paint brush set!'
scope :newest, -> {
order(arel_table[:created_at].desc) if arel_table[:created_at]
@ -70,12 +65,6 @@ class Item < ApplicationRecord
where('description NOT LIKE ?',
'%' + 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) {
Zone.matching_label(zone_label).
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
@nc_trade_value = begin
LebronNCValues.find_by_name(name)
rescue LebronNCValues::NotFound => error
Rails.logger.debug "Item #{id} (#{name}) <lookup>"
OwlsValueGuide.find_by_name(name)
rescue OwlsValueGuide::NotFound => error
Rails.logger.debug("No NC trade value listed for #{name} (#{id})")
nil
rescue LebronNCValues::NetworkError => error
rescue OwlsValueGuide::NetworkError => error
Rails.logger.error("Couldn't load nc_trade_value: #{error.full_message}")
nil
end
@ -162,7 +152,7 @@ class Item < ApplicationRecord
end
def pb?
I18n.with_locale(:en) { self.description.include?(PAINTBRUSH_SET_DESCRIPTION) }
I18n.with_locale(:en) { self.description == PAINTBRUSH_SET_DESCRIPTION }
end
def np?
@ -273,19 +263,8 @@ class Item < ApplicationRecord
end
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_compatible_body_ids = compatible_body_ids(use_cached: false)
self.cached_predicted_fully_modeled =
predicted_fully_modeled?(use_cached: false)
self.save!
end
@ -299,16 +278,8 @@ class Item < ApplicationRecord
write_attribute('species_support_ids', replacement)
end
def modeling_hinted_done?
modeling_status_hint == "done" || modeling_status_hint == "glitchy"
end
def predicted_body_ids
@predicted_body_ids ||= if modeling_hinted_done?
# 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)
@predicted_body_ids ||= if compatible_body_ids.include?(0)
# 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
# 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
# our prediction, though we'll revise it if we see another body ID.
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
# 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
# color. (As an optimization, we omit standard colors, other than the
# 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)
# 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_pairs.each do |(color_id, body_id)|
h[body_id] ||= []
@ -350,34 +309,25 @@ class Item < ApplicationRecord
end
end
# Find non-basic colors with at least one unique compatible body (size == 1).
# This means we'll predict "all Maraquan pets" only if the item fits a
# Maraquan pet with a unique body (like the Maraquan Acara), not if it only
# fits the Maraquan Mynci (which shares its body with basic Myncis).
# Find non-basic colors with at least one unique compatible body. (This
# means we'll ignore e.g. the Maraquan Mynci, which has the same body as
# the Blue Mynci, as not indicating Maraquan compatibility in general.)
modelable_color_ids =
compatible_color_ids_by_body_id.
filter { |k, v| v.size == 1 && v.first != "basic" }.
values.map(&:first).uniq
# We can model on basic pets if we find a basic body that doesn't also fit
# any modelable colors. This way, if an item fits both basic Mynci and
# Maraquan Acara (a modelable color), we treat it as "Maraquan item" not
# "basic item", avoiding false predictions for all basic pets.
# We can model on basic pets (perhaps in addition to the above) if we
# find at least one compatible basic body that doesn't *also* fit any of
# the modelable colors we identified above.
basic_is_modelable =
compatible_color_ids_by_body_id.values.
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 =
(basic_is_modelable ? PetType.basic : PetType.none).
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
end
end
@ -429,8 +379,7 @@ class Item < ApplicationRecord
body_ids_by_species_by_color
end
def predicted_fully_modeled?(use_cached: true)
return cached_predicted_fully_modeled? if use_cached
def predicted_fully_modeled?
predicted_missing_body_ids.empty?
end
@ -438,40 +387,11 @@ class Item < ApplicationRecord
compatible_body_ids.size.to_f / predicted_body_ids.size
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={})
result = super({
super({
only: [:id, :name, :description, :thumbnail_url, :rarity_index],
methods: [:zones_restrict],
}.merge(options))
if options[:include_trade_counts]
trades = visible_trades(
user: options[:current_user],
remote_ip: options[:remote_ip]
)
result['num_trades_offering'] = trades[:offering].size
result['num_trades_seeking'] = trades[:seeking].size
end
if options[:include_nc_trade_value]
result['nc_trade_value'] = nc_trade_value
end
result
end
def compatible_body_ids(use_cached: true)
@ -623,19 +543,22 @@ class Item < ApplicationRecord
Item.appearances_for([self], target, ...)[id]
end
def appearances_by_occupied_zone_label
zones_by_id = occupied_zones.to_h { |z| [z.id, z] }
def appearances_by_occupied_zone_id
{}.tap do |h|
appearances.each do |appearance|
appearance.occupied_zone_ids.each do |zone_id|
zone_label = zones_by_id[zone_id].label
h[zone_label] ||= []
h[zone_label] << appearance
h[zone_id] ||= []
h[zone_id] << appearance
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
# pet type or an alt style).
def self.appearances_for(items, target, swf_asset_includes: [])
@ -696,10 +619,21 @@ class Item < ApplicationRecord
end
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
# `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
items

View file

@ -5,7 +5,7 @@ class Item
end
# 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?
dyeworks_base_buyable? && dyeworks_dyeable?
end
@ -18,14 +18,14 @@ class Item
end
# 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?
dyeworks_permanent? || dyeworks_limited_active?
end
# 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
# tracks this, not us!)
# Mall at any time, rather than as part of a limited-time event. (Owls tracks
# this, not us!)
DYEWORKS_PERMANENT_PATTERN = /Permanent\s*Dyeworks/i
def dyeworks_permanent?
return false if nc_trade_value.nil?
@ -33,11 +33,11 @@ class Item
end
# 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
# 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?
return false unless dyeworks_limited?
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
# limited-time event. (This may return true even if the end date has
# passed, see `dyeworks_limited_active?`.) (Lebron tracks this, not us!)
DYEWORKS_LIMITED_PATTERN = /Dyeworks\s*Thru/i
# passed, see `dyeworks_limited_active?`.) (Owls tracks this, not us!)
DYEWORKS_LIMITED_PATTERN = /Limited\s*Dyeworks/i
def dyeworks_limited?
return false if nc_trade_value.nil?
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
# 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\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
return nil unless dyeworks_limited?

View file

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

View file

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

View file

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

View file

@ -3,18 +3,71 @@ class Pet < ApplicationRecord
attr_reader :items, :pet_state, :alt_style
def load!(timeout: nil)
raise ModelingDisabled unless Rails.configuration.modeling_enabled
scope :with_pet_type_color_ids, ->(color_ids) {
joins(:pet_type).where(PetType.arel_table[:id].in(color_ids))
}
viewer_data_hash = Neopets::CustomPets.fetch_viewer_data(name, timeout:)
use_modeling_snapshot(ModelingSnapshot.new(viewer_data_hash))
def load!(timeout: nil)
viewer_data = Neopets::CustomPets.fetch_viewer_data(name, timeout:)
use_viewer_data(viewer_data)
end
def use_modeling_snapshot(snapshot)
self.pet_type = snapshot.pet_type
@pet_state = snapshot.pet_state
@alt_style = snapshot.alt_style
@items = snapshot.items
def use_viewer_data(viewer_data)
pet_data = viewer_data[:custom_pet]
raise UnexpectedDataFormat unless pet_data[:species_id]
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
def wardrobe_query
@ -40,8 +93,11 @@ class Pet < ApplicationRecord
before_validation do
pet_type.save!
@pet_state.save! if @pet_state
if @pet_state
@pet_state.save!
@pet_state.handle_assets!
end
if @items
@items.each do |item|
item.save! if item.changed?
@ -61,5 +117,5 @@ class Pet < ApplicationRecord
end
class UnexpectedDataFormat < RuntimeError;end
class ModelingDisabled < RuntimeError;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,
:inverse_of => :contributed # in case of duplicates being merged
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
serialize :swf_asset_ids, coder: Serializers::IntegerSet, type: Array
belongs_to :pet_type
delegate :species_id, :species, :color_id, :color, to: :pet_type
alias_method :swf_asset_ids_from_association, :swf_asset_ids
scope :glitched, -> { where(glitched: true) }
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)) }
attr_writer :parent_swf_asset_relationships_to_update
# A simple ordering that tries to bring reliable pet states to the front.
scope :emotion_order, -> {
@ -103,16 +95,109 @@ class PetState < ApplicationRecord
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
"#{id}-#{pose.split('_').map(&:capitalize).join('-')}"
end
# Because our column is named `swf_asset_ids`, we need to ensure writes to
# it go to the attribute, and not the thing ActiveRecord does of finding the
# relevant `swf_assets`.
# TODO: Consider renaming the column to `cached_swf_asset_ids`?
def swf_asset_ids=(new_swf_asset_ids)
write_attribute(:swf_asset_ids, new_swf_asset_ids)
def self.from_pet_type_and_biology_info(pet_type, info)
swf_asset_ids = []
info.each do |zone_id, asset_info|
if zone_id.present? && asset_info
swf_asset_ids << asset_info[:part_id].to_i
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
private
@ -142,40 +227,5 @@ class PetState < ApplicationRecord
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

View file

@ -15,7 +15,10 @@ class PetType < ApplicationRecord
species = Species.find_by_name!(species_name)
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) {
joins(:species).order([Arel.sql("species_id = ? DESC"), species_id])
}
@ -27,16 +30,6 @@ class PetType < ApplicationRecord
merge(Species.order(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)
random_pet_types = []
@ -64,14 +57,6 @@ class PetType < ApplicationRecord
basic_image_hash || self['image_hash'] || 'deadbeef'
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
self.color || Color.new(id: self.color_id)
end
@ -86,6 +71,11 @@ class PetType < ApplicationRecord
species_human_name: possibly_new_species.human_name)
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
# 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
@ -123,7 +113,7 @@ class PetType < ApplicationRecord
end
def to_param
"#{possibly_new_color.to_param}-#{possibly_new_species.to_param}"
"#{color.human_name}-#{species.human_name}"
end
def fully_labeled?
@ -143,19 +133,6 @@ class PetType < ApplicationRecord
pet_states.count { |ps| ps.pose == "UNKNOWN" }
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
PetType.basic.distinct.pluck(:body_id)
end

View file

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

View file

@ -1,4 +1,7 @@
require 'addressable/template'
require 'async'
require 'async/barrier'
require 'async/semaphore'
class SwfAsset < ApplicationRecord
# We use the `type` column to mean something other than what Rails means!
@ -38,7 +41,7 @@ class SwfAsset < ApplicationRecord
{
swf: url,
png: image_url,
svg: svg_url,
svg: manifest_asset_urls[:svg],
canvas_library: manifest_asset_urls[:js],
manifest: manifest_url,
}
@ -183,18 +186,6 @@ class SwfAsset < ApplicationRecord
nil
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?
canvas_movie_library_url.present?
end
@ -329,12 +320,30 @@ class SwfAsset < ApplicationRecord
swf_asset
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
# fast concurrent execution!
def self.preload_manifests(swf_assets)
DTIRequests.load_many(max_at_once: 10) do |task|
swf_assets.each do |swf_asset|
task.async do
# Blocks all tasks beneath it.
barrier = Async::Barrier.new
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
# Don't save changes in this big async situation; we'll do it all
# in one batch after, to avoid too much database concurrency!
@ -345,6 +354,11 @@ class SwfAsset < ApplicationRecord
end
end
end
# Wait until all tasks are done.
barrier.wait
ensure
barrier.stop # If something goes wrong, clean up all tasks.
end
SwfAsset.transaction do

View file

@ -198,17 +198,6 @@ class User < ApplicationRecord
touch(:last_trade_activity_at)
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)
user = User.top_contributors.select(:points).limit(1).offset(offset).first
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`.
def send_amfphp_request(request, timeout: 10)
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
raise DownloadError, e.message
rescue RocketAMFExtensions::RemoteGateway::ConnectionError => e

View file

@ -1,39 +1,57 @@
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
# 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(
"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)
url = CATEGORY_PAGE_URL_TEMPLATE.expand(type:, cat:, page:, limit:)
def self.load_page(type, cat)
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
DTIRequests.get(url) do |response|
INTERNET.get(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})"
@ -44,174 +62,7 @@ module Neopets::NCMall
end
end
# Load all pages for a specific category.
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!
# Given a string of NC page data, parse the useful data out of it!
def self.parse_nc_page(nc_page_str)
begin
nc_page = JSON.parse(nc_page_str)
@ -221,14 +72,24 @@ module Neopets::NCMall
"failed to parse NC page response as JSON"
end
# v2 API returns items in a "data" array
unless nc_page.has_key? "data"
raise UnexpectedResponseFormat, "missing field data in v2 NC page"
unless nc_page.has_key? "object_data"
raise UnexpectedResponseFormat, "missing field object_data in NC page"
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"],
name: item_info["name"],
@ -239,24 +100,18 @@ module Neopets::NCMall
}
end
{
items:,
total_pages: nc_page["totalPages"].to_i,
page: nc_page["page"].to_i,
limit: nc_page["limit"].to_i,
}
{items:}
end
# 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)
discount_price = item_info["discountPrice"]
return nil unless discount_price.present? && discount_price > 0
{
price: discount_price,
begins_at: NST.at(item_info["discountBegin"]),
ends_at: NST.at(item_info["discountEnd"]),
begins_at: item_info["discountBegin"],
ends_at: item_info["discountEnd"],
}
end

View file

@ -1,6 +1,12 @@
require "async/http/internet/instance"
# While most of our NeoPass logic is built into Devise -> OmniAuth -> OIDC
# OmniAuth plugin, NeoPass also offers some supplemental APIs that we use here.
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)
linkages = load_linkages(access_token)
@ -26,10 +32,10 @@ module Neopets::NeoPass
LINKAGE_URL = "https://oidc.neopets.com/linkage/all"
def self.load_linkages(access_token)
linkages_str = Sync do
DTIRequests.get(
LINKAGE_URL,
[["Authorization", "Bearer #{access_token}"]],
) do |response|
INTERNET.get(LINKAGE_URL, [
["User-Agent", Rails.configuration.user_agent_for_neopets],
["Authorization", "Bearer #{access_token}"],
]) do |response|
if response.status != 200
raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{LINKAGE_URL})"

View file

@ -1,4 +1,5 @@
require "addressable/uri"
require "async/http/internet/instance"
require "json"
# 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
# order for us to operate. We never discard old files, we just keep going!
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)
# 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
# requests in parallel!
Sync do
DTIRequests.get(uri) do |response|
INTERNET.get(uri, [
["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} (#{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
= link_to view_or_edit_alt_style_url(alt_style) do
= 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
%p
Added
= time_tag alt_style.created_at,
title: alt_style.created_at.to_formatted_s(:long_nst) do
= time_with_only_month_if_old alt_style.created_at
- if support_staff? && !alt_style.real_series_name?
%p ⚠️ Needs series name
= time_with_only_month_if_old alt_style.created_at

View file

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

View file

@ -22,16 +22,6 @@
[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,
class: "rainbow-pool-filters" do |f|
%fieldset
@ -44,12 +34,11 @@
selected: @species&.human_name, include_blank: "Species…"
= f.submit "Go", name: nil
- if @alt_styles.present?
= will_paginate @alt_styles, class: "rainbow-pool-pagination"
%ul.rainbow-pool-list= render @alt_styles
= will_paginate @alt_styles, class: "rainbow-pool-pagination"
- else
%p.rainbow-pool-no-results We don't have any styles matching that search.
= will_paginate @alt_styles, class: "rainbow-pool-pagination"
%ul.rainbow-pool-list= render @alt_styles
= will_paginate @alt_styles, class: "rainbow-pool-pagination"
- content_for :stylesheets do
= stylesheet_link_tag "application/breadcrumbs"

View file

@ -21,9 +21,7 @@
}
- if swf_asset.canvas_movie?
%iframe{src: swf_asset_path(swf_asset, playing: outfit_viewer_is_playing ? true : nil)}
- elsif preferred_image_format == :svg && swf_asset.svg_url?
= image_tag swf_asset.svg_url, alt: "", loading: "lazy"
- elsif swf_asset.image_url?
- elsif swf_asset.image_url.present?
= image_tag swf_asset.image_url, alt: "", loading: "lazy"
- else
/ 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.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')
- 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?
%noscript

View file

@ -17,17 +17,11 @@
%th= t(".table.headings.user.#{@type}")
%th= t(".table.headings.lists")
%tbody
- prev_trade = nil
- sorted_vaguely_by_trade_activity(@trades).each do |trade|
%tr
%td{
'data-is-same-as-prev': same_vague_trade_timestamp?(trade, prev_trade)
}
= vague_trade_timestamp trade
%td
= trade.user.name
- if support_staff? && trade.user.shadowbanned?
%abbr{title: "Shadowbanned (Hidden from most viewers; you can see because you're Support staff"} 🕶️ SBan
= vague_trade_timestamp trade.user.last_trade_activity_at
%td= trade.user.name
%td
- if trade.lists.present?
%ul.trade-list-names
@ -38,7 +32,6 @@
= link_to t(".table.not_in_a_list.#{@type}"), user_closet_hangers_path(trade.user,
anchor: "closet-hangers-group-#{@type == :offering}"),
class: "not-in-a-list"
- prev_trade = trade
- else
%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.impress_2020'), impress_2020_url_for(item)
- if item.nc_trade_value
= link_to lebron_url_for(item),
title: nc_trade_value_updated_at_text(item.nc_trade_value) do
= t 'items.show.resources.lebron_value',
value: nc_trade_value_estimate_text(item.nc_trade_value)
- elsif item.nc?
= link_to lebron_url_for(item) do
= t 'items.show.resources.lebron'
= link_to t('items.show.resources.owls', value: item.nc_trade_value.value_text),
"https://www.neopets.com/~owls",
title: nc_trade_value_updated_at_text(item.nc_trade_value)
- unless item.nc?
= link_to t('items.show.resources.shop_wizard'), shop_wizard_url_for(item)
= link_to t('items.show.resources.trading_post'), trading_post_url_for(item)
@ -50,8 +46,6 @@
= link_to t('items.show.closet_hangers.button'),
user_closet_hangers_path(current_user),
class: 'user-lists-form-opener'
- if support_staff?
= link_to "Edit", edit_item_path(item)
- 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

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
%section
%h3 Occupies
- if @appearances_by_occupied_zone_label.present?
- if @appearances_by_occupied_zone.present?
%ul
- @appearances_by_occupied_zone_label.each do |label, appearances|
- @appearances_by_occupied_zone.each do |zone, appearances_in_zone|
%li<
= label
- if item_zone_partial_fit? appearances, @all_appearances
= zone.label
- if item_zone_partial_fit? appearances_in_zone, @all_appearances
= " "
%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
%span.no-zones (None)

View file

@ -92,7 +92,7 @@
title: "This recipe is NOT currently scheduled to be removed " +
"from Dyeworks. It might not stay forever, but it's also " +
"not part of a known limited-time event, like most " +
"Dyeworks items are. (Thanks Lebron team!)"
"Dyeworks items are. (Thanks Owls team!)"
}
(Always available)
- elsif item.dyeworks_limited_final_date.present?
@ -100,14 +100,14 @@
title: "This recipe is part of a limited-time Dyeworks " +
"event. The last day you can dye this is " +
"#{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)})
- elsif item.dyeworks_limited?
%span.dyeworks-timeframe{
title: "This recipe is part of a limited-time Dyeworks " +
"event, and is scheduled to be removed from the NC Mall " +
"soon. (Thanks Lebron team!)"
"soon. (Thanks Owls team!)"
}
(Limited-time)
@ -169,7 +169,7 @@
alt: "Item thumbnail for #{color.pb_item_name}"
- elsif color
= 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
- else
= image_tag "https://images.neopets.com/items/starter_red_pb.gif",
@ -210,9 +210,11 @@
- if @items[:other_nc].any?(&:nc_trade_value)
: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
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])}
%thead
@ -225,17 +227,17 @@
- content_for :subtitle, flush: true do
- if item.nc_trade_value.present?
- if nc_trade_value_is_estimate(item.nc_trade_value)
= link_to 'https://www.neopets.com/~lebron', target: '_blank',
class: "nc-trade-guide-info-link",
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
%span.nc-trade-guide-info-label [Lebron]
= link_to "https://www.neopets.com/~owls",
class: "owls-info-link", target: "_blank",
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.owls-info-label [Owls]
Estimated value:
= nc_trade_value_estimate_text(item.nc_trade_value)
- else
= link_to 'https://www.neopets.com/~lebron', target: '_blank',
class: "nc-trade-guide-info-link",
title: "Lebron 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]
= link_to "https://www.neopets.com/~owls",
class: "owls-info-link", target: "_blank",
title: "Owls keeps track of how to get certain special items, even when there isn't a clear NC trade estimate." do
%span.owls-info-label [Owls]
Trade info:
#{item.nc_trade_value.value_text}

View file

@ -4,19 +4,20 @@
%p#pet-not-found.alert= t 'pets.load.not_found'
- hide_after Date.new(2024, 12, 8) do
- if show_announcement?
%section.announcement
= image_tag "about/announcement.png", width: 70, height: 70,
srcset: {"about/announcement@2x.png": "2x"}
.content
%p
%strong Oh wow, it's busy this time of year!
We've temporarily moved to a bigger server, to help us handle the extra
load. Hopefully this keeps us running smooth!
%strong
🎃
= 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
Happy holidays, everyone! Here's hoping you, and your families, and your
precious pets—both online and off—stay happy and healthy for the year
to come 💜
By the way, we had a bug where modeling new styles wasn't working for a
little while. Fixed now! 🤞
#outfit-forms
#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
= link_to "Rainbow Pool", pet_types_path
%li
= link_to @pet_type.possibly_new_color.human_name,
pet_types_path(color: @pet_type.possibly_new_color.human_name)
= link_to @pet_type.color.human_name,
pet_types_path(color: @pet_type.color.human_name)
%li{"data-relation-to-prev": "sibling"}
= link_to @pet_type.possibly_new_species.human_name,
pet_types_path(species: @pet_type.possibly_new_species.human_name)
= link_to @pet_type.species.human_name,
pet_types_path(species: @pet_type.species.human_name)
%li
= link_to "Appearances", @pet_type
%li
\##{@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|
= f.errors
= f.fields do
= f.radio_grid_fieldset "Pose" do
- pose_options.each do |pose|
= f.radio_field do
= f.radio_button :pose, pose
= pose_name(pose)
- if @reference_pet_type
= link_to @reference_pet_type, target: "_blank", class: "reference-link" do
= pet_type_image @reference_pet_type, :happy, :face
%span Reference: #{@reference_pet_type.human_name}
= external_link_icon
= f.field do
= f.label :glitched, "Glitched?"
= form_with model: [@pet_type, @pet_state] do |f|
- if @pet_state.errors.any?
%p
Could not save:
%ul.errors
- @pet_state.errors.each do |error|
%li= error.full_message
%dl
%dt Pose
%dd
%ul.pose-options
- pose_options.each do |pose|
%li
%label
= f.radio_button :pose, pose
= pose_name pose
%dt Glitched?
%dd
= f.select :glitched, [["✅ Not marked as Glitched", false],
["👾 Yes, it's bad news bonko'd", true]]
= 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
["👾 Yes, it's bad news bonko'd", true]]
= f.submit "Save"
- content_for :stylesheets do
= stylesheet_link_tag "application/breadcrumbs"
= stylesheet_link_tag "application/magic-magnifier"
= 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"
- content_for :javascripts do
= javascript_include_tag "magic-magnifier"
= javascript_include_tag "outfit-viewer"
= javascript_include_tag "pet_states/support-outfit-viewer"

View file

@ -10,18 +10,6 @@
[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|
%fieldset
%legend Filter by:
@ -34,7 +22,7 @@
%ui.rainbow-pool-list= render @pet_types
= will_paginate @pet_types, class: "rainbow-pool-pagination"
- 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
= stylesheet_link_tag "application/rainbow-pool"

View file

@ -5,11 +5,11 @@
%li
= link_to "Rainbow Pool", pet_types_path
%li
= link_to @pet_type.possibly_new_color.human_name,
pet_types_path(color: @pet_type.possibly_new_color.human_name)
= link_to @pet_type.color.human_name,
pet_types_path(color: @pet_type.color.human_name)
%li{"data-relation-to-prev": "sibling"}
= link_to @pet_type.possibly_new_species.human_name,
pet_types_path(species: @pet_type.possibly_new_species.human_name)
= link_to @pet_type.species.human_name,
pet_types_path(species: @pet_type.species.human_name)
%li
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
require "fileutils"
# path to your application root.
APP_ROOT = File.expand_path("..", __dir__)
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.
# 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")
# puts "\n== Copying sample files =="
@ -23,25 +25,9 @@ FileUtils.chdir APP_ROOT do
puts "\n== Preparing database =="
system! "bin/rails db:prepare"
system! "bin/rails db:reset" if ARGV.include?("--reset")
puts "\n== Importing public modeling data =="
system! "bin/rails public_data:pull"
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 =="
system! "bin/rails log:clear tmp:clear"
unless ARGV.include?("--skip-server")
puts "\n== Starting development server =="
STDOUT.flush # flush the output before exec(2) so that it displays
exec "bin/dev"
end
puts "\n== Restarting application server =="
system! "bin/rails restart"
end

View file

@ -5,18 +5,25 @@ require "rails"
# 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
# app footprint smaller.
# require "active_model/railtie"
# require "active_job/railtie"
require "active_record/railtie"
# require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
# require "action_mailbox/engine"
# require "action_text/engine"
require "action_view/railtie"
# require "action_cable/engine"
require "rails/test_unit/railtie"
#
# Disabled:
# - active_storage/engine
# - active_job/railtie
# - action_cable/engine
# - action_mailbox/engine
# - action_text/engine
%w(
active_record/railtie
action_controller/railtie
action_view/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
# you've limited to :test, :development, or :production.
@ -25,12 +32,12 @@ Bundler.require(*Rails.groups)
module OpenneoImpressItems
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.1
config.load_defaults 7.1
# 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.
# 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.
#
@ -64,10 +71,7 @@ module OpenneoImpressItems
# 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
# should hopefully be clear who we are from context!
#
# 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)"
config.user_agent_for_neopets = "Dress to Impress"
# 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,

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