Compare commits
No commits in common. "main" and "rainbow-pool" have entirely different histories.
main
...
rainbow-po
587 changed files with 2291 additions and 15831 deletions
|
|
@ -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
|
||||
|
|
|
|||
5
.devcontainer/create-db.sql
Normal file
5
.devcontainer/create-db.sql
Normal 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;
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
19
.devcontainer/post-create.sh
Executable 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
|
||||
|
|
@ -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}"
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -4,8 +4,6 @@ log/*.log
|
|||
tmp/**/*
|
||||
.env
|
||||
.env.*
|
||||
/spec/examples.txt
|
||||
/.yardoc
|
||||
|
||||
/app/assets/builds/*
|
||||
!/app/assets/builds/.keep
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
# Run the linter, and all our tests.
|
||||
yarn lint --max-warnings=0 --fix && bin/rake test spec
|
||||
yarn lint --max-warnings=0 --fix
|
||||
|
|
|
|||
1
.rspec
1
.rspec
|
|
@ -1 +0,0 @@
|
|||
--require spec_helper
|
||||
|
|
@ -1 +1 @@
|
|||
3.4.5
|
||||
3.3.5
|
||||
|
|
|
|||
40
Gemfile
40
Gemfile
|
|
@ -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,7 +18,8 @@ gem 'sprockets', '~> 4.2'
|
|||
gem 'haml', '~> 6.1', '>= 6.1.1'
|
||||
gem 'sass-rails', '~> 6.0'
|
||||
gem 'terser', '~> 1.1', '>= 1.1.17'
|
||||
gem 'jsbundling-rails', '~> 1.3'
|
||||
gem 'react-rails', '~> 2.7', '>= 2.7.1'
|
||||
gem 'jsbundling-rails', '~> 1.1'
|
||||
gem 'turbo-rails', '~> 2.0'
|
||||
|
||||
# For authentication.
|
||||
|
|
@ -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,5 @@ 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
|
||||
|
||||
# For automated tests.
|
||||
group :development, :test do
|
||||
gem "rspec-rails", "~> 7.0"
|
||||
end
|
||||
group :test do
|
||||
gem "webmock", "~> 3.24"
|
||||
end
|
||||
gem "solargraph", "~> 0.50.0", group: :development
|
||||
gem "solargraph-rails", "~> 1.1", group: :development
|
||||
|
|
|
|||
515
Gemfile.lock
515
Gemfile.lock
|
|
@ -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,145 +277,119 @@ 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)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.7)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (7.1.1)
|
||||
actionpack (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.6)
|
||||
rubocop (1.82.1)
|
||||
rexml (3.3.7)
|
||||
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 +405,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 +431,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,37 +479,31 @@ 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)
|
||||
jsbundling-rails (~> 1.3)
|
||||
httparty (~> 0.22.0)
|
||||
jsbundling-rails (~> 1.1)
|
||||
letter_opener (~> 1.8, >= 1.8.1)
|
||||
memory_profiler (~> 1.0)
|
||||
mysql2 (~> 0.5.5)
|
||||
|
|
@ -563,13 +511,13 @@ 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)
|
||||
rspec-rails (~> 7.0)
|
||||
ruby-vips (~> 2.2)
|
||||
react-rails (~> 2.7, >= 2.7.1)
|
||||
sanitize (~> 6.0, >= 6.0.2)
|
||||
sass-rails (~> 6.0)
|
||||
sentry-rails (~> 5.12)
|
||||
|
|
@ -583,11 +531,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
|
||||
|
|
|
|||
|
|
@ -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
161
README.md
|
|
@ -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
|
||||
|
|
|
|||
BIN
app/assets/images/about/announcement-broom.png
Normal file
BIN
app/assets/images/about/announcement-broom.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
app/assets/images/about/announcement-broom@2x.png
Normal file
BIN
app/assets/images/about/announcement-broom@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 KiB |
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -81,35 +81,23 @@ class SpeciesFacePickerOptions extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: If it ever gets wide support, remove this in favor of the CSS rule
|
||||
// `interpolate-size: allow-keywords`, to animate directly from `auto`.
|
||||
// https://drafts.csswg.org/css-values-5/#valdef-interpolate-size-allow-keywords
|
||||
class MeasuredContainer extends HTMLElement {
|
||||
static observedAttributes = ["style"];
|
||||
|
||||
class MeasuredContent extends HTMLElement {
|
||||
connectedCallback() {
|
||||
setTimeout(() => this.#measure(), 0);
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
// When `--natural-width` gets morphed away by Turbo, measure it again!
|
||||
if (this.style.getPropertyValue("--natural-width") === "") {
|
||||
this.#measure();
|
||||
}
|
||||
}
|
||||
|
||||
#measure() {
|
||||
// Find our `<measured-content>` child, and set our natural width as
|
||||
// `var(--natural-width)` in the context of our CSS styles.
|
||||
const content = this.querySelector("measured-content");
|
||||
if (content == null) {
|
||||
throw new Error(`<measured-container> must contain a <measured-content>`);
|
||||
// Find our `<measured-container>` parent, and set our natural width
|
||||
// as `var(--natural-width)` in the context of its CSS styles.
|
||||
const container = this.closest("measured-container");
|
||||
if (container == null) {
|
||||
throw new Error(`<measured-content> must be in a <measured-container>`);
|
||||
}
|
||||
this.style.setProperty("--natural-width", content.offsetWidth + "px");
|
||||
container.style.setProperty("--natural-width", this.offsetWidth + "px");
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("species-color-picker", SpeciesColorPicker);
|
||||
customElements.define("species-face-picker", SpeciesFacePicker);
|
||||
customElements.define("species-face-picker-options", SpeciesFacePickerOptions);
|
||||
customElements.define("measured-container", MeasuredContainer);
|
||||
customElements.define("measured-content", MeasuredContent);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ $container_width: 800px
|
|||
input, button, select, label
|
||||
cursor: pointer
|
||||
|
||||
input[type=text], input[type=password], input[type=search], input[type=number], input[type=email], input[type=url], select, textarea
|
||||
input[type=text], input[type=password], input[type=search], input[type=number], input[type=email], select, textarea
|
||||
border-radius: 3px
|
||||
background: #fff
|
||||
border: 1px solid $input-border-color
|
||||
|
|
@ -83,15 +83,6 @@ input[type=text], input[type=password], input[type=search], input[type=number],
|
|||
&:focus, &:active
|
||||
color: inherit
|
||||
|
||||
select:has(option[value='']:checked)
|
||||
color: #666
|
||||
|
||||
option[value='']
|
||||
color: #666
|
||||
|
||||
option:not([value=''])
|
||||
color: $text-color
|
||||
|
||||
textarea
|
||||
font: inherit
|
||||
|
||||
|
|
|
|||
|
|
@ -3,20 +3,10 @@ body.use-responsive-design
|
|||
max-width: 100%
|
||||
padding-inline: 1rem
|
||||
box-sizing: border-box
|
||||
padding-top: 0
|
||||
|
||||
#main-nav
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
|
||||
#home-link, #userbar
|
||||
position: static
|
||||
|
||||
#home-link
|
||||
padding-inline: .5rem
|
||||
margin-inline: -.5rem
|
||||
margin-right: auto
|
||||
margin-left: 1rem
|
||||
padding-inline: 0
|
||||
|
||||
#userbar
|
||||
margin-left: auto
|
||||
text-align: right
|
||||
margin-right: 1rem
|
||||
|
|
|
|||
18
app/assets/stylesheets/alt_styles/_index.sass
Normal file
18
app/assets/stylesheets/alt_styles/_index.sass
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
body.alt_styles-index
|
||||
.alt-styles-header
|
||||
margin-top: 1em
|
||||
margin-bottom: .5em
|
||||
|
||||
.alt-styles-list
|
||||
list-style: none
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
gap: 1.5em
|
||||
|
||||
.alt-style
|
||||
text-align: center
|
||||
width: 80px
|
||||
|
||||
.alt-style-thumbnail
|
||||
width: 80px
|
||||
height: 80px
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
.alt-style-preview
|
||||
width: 300px
|
||||
height: 300px
|
||||
margin: 0 auto
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
@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
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
@import partials/jquery.jgrowl
|
||||
|
||||
@import alt_styles/index
|
||||
@import closet_hangers/index
|
||||
@import closet_lists/form
|
||||
@import neopets_page_import_tasks/new
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
#title:has(+ .breadcrumbs)
|
||||
margin-bottom: .125em
|
||||
|
||||
.breadcrumbs
|
||||
list-style-type: none
|
||||
display: flex
|
||||
flex-direction: row
|
||||
margin-block: .5em
|
||||
font-size: .85em
|
||||
|
||||
li
|
||||
display: flex
|
||||
|
||||
li:not(:first-child)
|
||||
&::before
|
||||
margin-inline: .35em
|
||||
content: "→"
|
||||
|
||||
&[data-relation-to-prev=sibling]::before
|
||||
content: "+"
|
||||
|
||||
&[data-relation-to-prev=menu]::before
|
||||
content: "-"
|
||||
|
|
@ -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 corner—this 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
@import "clean/mixins"
|
||||
|
||||
=context-button
|
||||
+awesome-button
|
||||
+awesome-button-color(#aaaaaa)
|
||||
+opacity(0.9)
|
||||
font-size: 80%
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
support-outfit-viewer
|
||||
margin-block: 1em
|
||||
|
||||
.fields li[data-type=radio-grid]
|
||||
--num-columns: 3
|
||||
|
||||
.reference-link
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .5em
|
||||
padding-inline: .5em
|
||||
|
||||
img
|
||||
height: 2em
|
||||
width: auto
|
||||
5
app/assets/stylesheets/pet_states/show.sass
Normal file
5
app/assets/stylesheets/pet_states/show.sass
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
outfit-viewer
|
||||
margin: 0 auto
|
||||
|
||||
dt
|
||||
cursor: help
|
||||
|
|
@ -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!
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
.rainbow-pool-filters
|
||||
margin-block: .5em
|
||||
|
||||
.pet-filters
|
||||
fieldset
|
||||
display: flex
|
||||
flex-direction: row
|
||||
|
|
@ -14,20 +12,19 @@
|
|||
display: contents
|
||||
font-weight: bold
|
||||
|
||||
select
|
||||
width: 16ch
|
||||
[role=navigation]
|
||||
margin-block: .5em
|
||||
text-align: center
|
||||
|
||||
.rainbow-pool-list
|
||||
.pet-types
|
||||
list-style-type: none
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
justify-content: center
|
||||
gap: .5em
|
||||
|
||||
--preview-base-width: 150px
|
||||
|
||||
> li
|
||||
width: var(--preview-base-width)
|
||||
width: 150px
|
||||
max-width: calc(50% - .25em)
|
||||
min-width: 150px
|
||||
box-sizing: border-box
|
||||
|
|
@ -43,7 +40,7 @@
|
|||
outline: 1px solid $module-border-color
|
||||
background: $module-bg-color
|
||||
|
||||
.preview
|
||||
img
|
||||
width: 100%
|
||||
height: auto
|
||||
aspect-ratio: 1 / 1
|
||||
|
|
@ -56,19 +53,3 @@
|
|||
margin: 0 auto
|
||||
position: relative
|
||||
z-index: 1
|
||||
|
||||
.info
|
||||
font-size: .85em
|
||||
p
|
||||
margin-block: .25em
|
||||
|
||||
.rainbow-pool-pagination
|
||||
margin-block: .5em
|
||||
display: flex
|
||||
justify-content: center
|
||||
gap: 1em
|
||||
|
||||
.rainbow-pool-no-results
|
||||
margin-block: 1em
|
||||
text-align: center
|
||||
font-style: italic
|
||||
|
|
@ -1,8 +1,43 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
.rainbow-pool-list
|
||||
--preview-base-width: 200px
|
||||
margin-bottom: 2em
|
||||
.pet-states
|
||||
list-style-type: none
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
justify-content: center
|
||||
gap: .5em
|
||||
|
||||
> li
|
||||
width: 200px
|
||||
max-width: calc(50% - .25em)
|
||||
min-width: 150px
|
||||
box-sizing: border-box
|
||||
text-align: center
|
||||
|
||||
a
|
||||
display: block
|
||||
border-radius: 1em
|
||||
padding: .5em
|
||||
background: white
|
||||
text-decoration: none
|
||||
&:hover
|
||||
outline: 1px solid $module-border-color
|
||||
background: $module-bg-color
|
||||
|
||||
outfit-viewer
|
||||
width: 100%
|
||||
height: auto
|
||||
aspect-ratio: 1 / 1
|
||||
position: relative
|
||||
z-index: 0
|
||||
margin-bottom: -1em
|
||||
|
||||
.name
|
||||
background: inherit
|
||||
padding: .25em .5em
|
||||
border-radius: .5em
|
||||
position: relative
|
||||
z-index: 1
|
||||
|
||||
.glitched
|
||||
cursor: help
|
||||
|
|
|
|||
|
|
@ -1,45 +1,23 @@
|
|||
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
|
||||
@alt_styles = AltStyle.includes(:species, :color, :swf_assets).
|
||||
order(:species_id, :color_id)
|
||||
|
||||
@series_name = params[:series]
|
||||
@color = find_color
|
||||
@species = find_species
|
||||
if params[:species_id]
|
||||
@species = Species.find(params[:species_id])
|
||||
@alt_styles = @alt_styles.merge(@species.alt_styles)
|
||||
end
|
||||
|
||||
@alt_styles = AltStyle.includes(:color, :species, :swf_assets)
|
||||
@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 going to link to the HTML5 image URL, 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,62 +25,9 @@ class AltStylesController < ApplicationController
|
|||
methods: [:urls, :known_glitches],
|
||||
}
|
||||
},
|
||||
methods: [:series_main_name, :adjective_name],
|
||||
methods: [:series_name, :adjective_name, :thumbnail_url],
|
||||
)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@alt_style = AltStyle.find params[:id]
|
||||
end
|
||||
|
||||
def update
|
||||
@alt_style = AltStyle.find params[:id]
|
||||
|
||||
if @alt_style.update(alt_style_params)
|
||||
flash[:notice] = "\"#{@alt_style.full_name}\" successfully saved!"
|
||||
redirect_to destination_after_save
|
||||
else
|
||||
render action: :edit, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def alt_style_params
|
||||
params.require(:alt_style).
|
||||
permit(:real_series_name, :real_full_name, :thumbnail_url)
|
||||
end
|
||||
|
||||
def find_color
|
||||
if params[:color]
|
||||
Color.find_by(name: params[:color])
|
||||
end
|
||||
end
|
||||
|
||||
def find_species
|
||||
if params[:species_id]
|
||||
Species.find_by(id: params[:species_id])
|
||||
elsif params[:species]
|
||||
Species.find_by(name: params[:species])
|
||||
end
|
||||
end
|
||||
|
||||
def destination_after_save
|
||||
if params[:next] == "unlabeled-style"
|
||||
next_unlabeled_style_path
|
||||
else
|
||||
alt_styles_path
|
||||
end
|
||||
end
|
||||
|
||||
def next_unlabeled_style_path
|
||||
unlabeled_style = AltStyle.unlabeled.newest.first
|
||||
if unlabeled_style
|
||||
edit_alt_style_path(unlabeled_style, next: "unlabeled-style")
|
||||
else
|
||||
alt_styles_path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -110,13 +110,5 @@ class ApplicationController < ActionController::Base
|
|||
Rails.logger.debug "Using return_to path: #{return_to.inspect}"
|
||||
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?
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -66,24 +47,29 @@ class OutfitsController < ApplicationController
|
|||
end
|
||||
|
||||
def new
|
||||
@colors = Color.alphabetical
|
||||
@colors = Color.funny.alphabetical
|
||||
@species = Species.alphabetical
|
||||
|
||||
newest_items = Item.newest.limit(18)
|
||||
@newest_modeled_items, @newest_unmodeled_items =
|
||||
newest_items.partition(&:predicted_fully_modeled?)
|
||||
# HACK: Skip this in development, because it's slow!
|
||||
unless Rails.env.development?
|
||||
newest_items = Item.newest.
|
||||
select(:id, :name, :updated_at, :thumbnail_url, :rarity_index, :is_manually_nc)
|
||||
.limit(18)
|
||||
@newest_modeled_items, @newest_unmodeled_items =
|
||||
newest_items.partition(&:predicted_fully_modeled?)
|
||||
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color = {}
|
||||
@newest_unmodeled_items_predicted_modeled_ratio = {}
|
||||
@newest_unmodeled_items.each do |item|
|
||||
h = item.predicted_missing_nonstandard_body_ids_by_species_by_color
|
||||
standard_body_ids_by_species = item.
|
||||
predicted_missing_standard_body_ids_by_species
|
||||
if standard_body_ids_by_species.present?
|
||||
h[:standard] = standard_body_ids_by_species
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color = {}
|
||||
@newest_unmodeled_items_predicted_modeled_ratio = {}
|
||||
@newest_unmodeled_items.each do |item|
|
||||
h = item.predicted_missing_nonstandard_body_ids_by_species_by_color
|
||||
standard_body_ids_by_species = item.
|
||||
predicted_missing_standard_body_ids_by_species
|
||||
if standard_body_ids_by_species.present?
|
||||
h[:standard] = standard_body_ids_by_species
|
||||
end
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color[item] = h
|
||||
@newest_unmodeled_items_predicted_modeled_ratio[item] = item.predicted_modeled_ratio
|
||||
end
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color[item] = h
|
||||
@newest_unmodeled_items_predicted_modeled_ratio[item] = item.predicted_modeled_ratio
|
||||
end
|
||||
|
||||
@species_count = Species.count
|
||||
|
|
@ -136,40 +122,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])
|
||||
|
|
|
|||
|
|
@ -1,56 +1,6 @@
|
|||
class PetStatesController < ApplicationController
|
||||
before_action :support_staff_only
|
||||
before_action :find_pet_state
|
||||
before_action :preload_assets
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @pet_state.update(pet_state_params)
|
||||
flash[:notice] = "Pet appearance \##{@pet_state.id} successfully saved!"
|
||||
redirect_to destination_after_save
|
||||
else
|
||||
render action: :edit, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def find_pet_state
|
||||
@pet_type = PetType.find_by_param!(params[:pet_type_name])
|
||||
def show
|
||||
@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
|
||||
|
|
|
|||
|
|
@ -1,61 +1,27 @@
|
|||
class PetTypesController < ApplicationController
|
||||
def index
|
||||
respond_to do |format|
|
||||
format.html {
|
||||
@species_names = Species.order(:name).map(&:human_name)
|
||||
@color_names = Color.order(:name).map(&:human_name)
|
||||
@species_names = Species.order(:name).map(&:human_name)
|
||||
@color_names = Color.order(:name).map(&:human_name)
|
||||
|
||||
if params[:species].present?
|
||||
@selected_species = Species.find_by!(name: params[:species])
|
||||
@selected_species_name = @selected_species.human_name
|
||||
end
|
||||
if params[:color].present?
|
||||
@selected_color = Color.find_by!(name: params[:color])
|
||||
@selected_color_name = @selected_color.human_name
|
||||
end
|
||||
@selected_order =
|
||||
if @selected_species.present? || @selected_color.present?
|
||||
:alphabetical
|
||||
else
|
||||
:newest
|
||||
end
|
||||
if params[:species].present?
|
||||
@selected_species = Species.find_by!(name: params[:species])
|
||||
@selected_species_name = @selected_species.human_name
|
||||
end
|
||||
if params[:color].present?
|
||||
@selected_color = Color.find_by!(name: params[:color])
|
||||
@selected_color_name = @selected_color.human_name
|
||||
end
|
||||
|
||||
@pet_types = PetType.
|
||||
includes(:color, :species, :pet_states).
|
||||
paginate(page: params[:page], per_page: 30)
|
||||
@pet_types = PetType.
|
||||
includes(:color, :species).
|
||||
order(created_at: :desc).
|
||||
paginate(page: params[:page], per_page: 30)
|
||||
|
||||
@pet_types.where!(species_id: @selected_species) if @selected_species
|
||||
@pet_types.where!(color_id: @selected_color) if @selected_color
|
||||
if @selected_order == :newest
|
||||
@pet_types.order!(created_at: :desc)
|
||||
elsif @selected_order == :alphabetical
|
||||
@pet_types.merge!(Color.alphabetical).merge!(Species.alphabetical)
|
||||
end
|
||||
|
||||
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 {
|
||||
if stale?(etag: PetState.last_updated_key)
|
||||
render json: {
|
||||
species: Species.order(:name).all,
|
||||
colors: Color.order(:name).all,
|
||||
supported_poses: PetState.all_supported_poses,
|
||||
}
|
||||
end
|
||||
}
|
||||
if @selected_species
|
||||
@pet_types = @pet_types.where(species_id: @selected_species)
|
||||
end
|
||||
if @selected_color
|
||||
@pet_types = @pet_types.where(color_id: @selected_color)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -80,7 +46,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
|
||||
|
|
@ -91,12 +59,11 @@ class PetTypesController < ApplicationController
|
|||
#
|
||||
# If no main poses are available, then we just make all the poses
|
||||
# "canonical", and show the whole mish-mash!
|
||||
MAIN_POSES = %w(HAPPY_FEM HAPPY_MASC SAD_FEM SAD_MASC SICK_FEM SICK_MASC)
|
||||
def group_pet_states(pet_states)
|
||||
pose_groups = pet_states.emotion_order.group_by(&:pose)
|
||||
main_groups =
|
||||
pose_groups.select { |k| PetState::MAIN_POSES.include?(k) }.values
|
||||
other_groups =
|
||||
pose_groups.reject { |k| PetState::MAIN_POSES.include?(k) }.values
|
||||
main_groups = pose_groups.select { |k| MAIN_POSES.include?(k) }.values
|
||||
other_groups = pose_groups.reject { |k| MAIN_POSES.include?(k) }.values
|
||||
|
||||
if main_groups.empty?
|
||||
return {canonical: other_groups.flatten(1).sort_by(&:pose), other: []}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
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::PetNotFound, with: :pet_not_found
|
||||
rescue_from PetType::DownloadError, SwfAsset::DownloadError, with: :asset_download_error
|
||||
rescue_from Pet::DownloadError, with: :pet_download_error
|
||||
rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format
|
||||
|
||||
def load
|
||||
raise Neopets::CustomPets::PetNotFound unless params[:name]
|
||||
# Uncomment this to temporarily disable modeling for most users.
|
||||
# return modeling_disabled unless user_signed_in? && current_user.admin?
|
||||
|
||||
raise Pet::PetNotFound unless params[:name]
|
||||
@pet = Pet.load(params[:name])
|
||||
points = contribute(current_user, @pet)
|
||||
|
||||
|
|
@ -45,6 +48,12 @@ class PetsController < ApplicationController
|
|||
:status => :not_found
|
||||
end
|
||||
|
||||
def asset_download_error(e)
|
||||
Rails.logger.warn e.message
|
||||
pet_load_error :long_message => t('pets.load.asset_download_error'),
|
||||
:status => :gateway_timeout
|
||||
end
|
||||
|
||||
def pet_download_error(e)
|
||||
Rails.logger.warn e.message
|
||||
Rails.logger.warn e.backtrace.join("\n")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
module AltStylesHelper
|
||||
def view_or_edit_alt_style_url(alt_style)
|
||||
if support_staff?
|
||||
edit_alt_style_path alt_style
|
||||
else
|
||||
wardrobe_path(
|
||||
species: alt_style.species_id,
|
||||
color: alt_style.color_id,
|
||||
style: alt_style.id,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -141,13 +141,6 @@ module ItemsHelper
|
|||
def auction_genie_url_for(item)
|
||||
AUCTION_GENIE_URL_TEMPLATE.expand(auctiongenie: item.name).to_s
|
||||
end
|
||||
|
||||
LEBRON_URL_TEMPLATE = Addressable::Template.new(
|
||||
"https://stylisher.club/search/{name}"
|
||||
)
|
||||
def lebron_url_for(item)
|
||||
LEBRON_URL_TEMPLATE.expand(name: item.name).to_s
|
||||
end
|
||||
|
||||
def format_contribution_count(count)
|
||||
" (×#{count})".html_safe if count > 1
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
module OutfitsHelper
|
||||
LAST_DAY_OF_ANNOUNCEMENT = Date.parse("2024-09-27")
|
||||
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,16 @@ 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
|
||||
)
|
||||
outfit = Outfit.new(pet_state:) if outfit.nil? && pet_state.present?
|
||||
|
||||
if outfit.nil?
|
||||
raise ArgumentError, "outfit viewer must have outfit or pet state"
|
||||
def outfit_viewer(outfit_or_options)
|
||||
outfit = if outfit_or_options.is_a? Hash
|
||||
Outfit.new(outfit_or_options)
|
||||
elsif outfit_or_options.is_a? Outfit
|
||||
outfit_or_options
|
||||
else
|
||||
raise TypeError, "must be an outfit or hash of options to create one"
|
||||
end
|
||||
|
||||
{outfit:, preferred_image_format:, html_options:}
|
||||
render partial: "outfit_viewer", locals: {outfit:}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -16,26 +16,7 @@ module PetStatesHelper
|
|||
when "UNCONVERTED"
|
||||
"Unconverted"
|
||||
else
|
||||
"Not labeled yet"
|
||||
end
|
||||
end
|
||||
|
||||
POSE_OPTIONS = %w(HAPPY_FEM SAD_FEM SICK_FEM HAPPY_MASC SAD_MASC SICK_MASC
|
||||
UNCONVERTED UNKNOWN)
|
||||
def pose_options
|
||||
POSE_OPTIONS
|
||||
end
|
||||
|
||||
def useful_pet_state_path(pet_type, pet_state)
|
||||
if support_staff?
|
||||
edit_pet_type_pet_state_path(pet_type, pet_state)
|
||||
else
|
||||
wardrobe_path(
|
||||
color: pet_type.color_id,
|
||||
species: pet_type.species_id,
|
||||
pose: pet_state.pose,
|
||||
state: pet_state.id,
|
||||
)
|
||||
"(Unknown)"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
module PetTypesHelper
|
||||
def moon_progress(num, total)
|
||||
nearest_quarter = (4.0 * num / total).round / 4.0
|
||||
if nearest_quarter >= 1
|
||||
"🌕️"
|
||||
elsif nearest_quarter >= 0.75
|
||||
"🌔"
|
||||
elsif nearest_quarter >= 0.5
|
||||
"🌓"
|
||||
elsif nearest_quarter >= 0.25
|
||||
"🌒"
|
||||
else
|
||||
"🌑"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -787,13 +777,8 @@ function StyleExplanation() {
|
|||
opacity="0.7"
|
||||
marginTop="2"
|
||||
>
|
||||
<Box
|
||||
as="a"
|
||||
href="/rainbow-pool/styles"
|
||||
target="_blank"
|
||||
textDecoration="underline"
|
||||
>
|
||||
Pet Styles
|
||||
<Box as="a" href="/alt-styles" target="_blank" textDecoration="underline">
|
||||
Alt Styles
|
||||
</Box>{" "}
|
||||
are NC items that override the pet's appearance via the{" "}
|
||||
<Box
|
||||
|
|
@ -804,7 +789,7 @@ function StyleExplanation() {
|
|||
>
|
||||
Styling Chamber
|
||||
</Box>
|
||||
. Not all items fit all Pet Styles. The pet's color doesn't have to match.
|
||||
. Not all items fit Alt Style pets. The pet's color doesn't have to match.
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,79 +4,49 @@ class AltStyle < ApplicationRecord
|
|||
belongs_to :species
|
||||
belongs_to :color
|
||||
|
||||
has_many :parent_swf_asset_relationships, as: :parent, dependent: :destroy
|
||||
has_many :parent_swf_asset_relationships, as: :parent
|
||||
has_many :swf_assets, through: :parent_swf_asset_relationships
|
||||
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 }
|
||||
before_create :infer_series_name
|
||||
before_create :infer_thumbnail_url
|
||||
|
||||
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
|
||||
}
|
||||
scope :unlabeled, -> { where(series_name: nil) }
|
||||
scope :newest, -> { order(created_at: :desc) }
|
||||
|
||||
def pet_name
|
||||
def name
|
||||
I18n.translate('pet_types.human_name', color_human_name: color.human_name,
|
||||
species_human_name: species.human_name)
|
||||
end
|
||||
|
||||
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
|
||||
self[:series_name] || "<New?>"
|
||||
end
|
||||
|
||||
def series_variant_name
|
||||
series_name.split(': ').first
|
||||
# You can use this to check whether `series_name` is returning the actual
|
||||
# value or its placeholder value.
|
||||
def has_real_series_name?
|
||||
self[: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
|
||||
|
||||
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 +54,28 @@ 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
|
||||
|
||||
# Until the end of 2024, assume new alt styles are from the "Nostalgic"
|
||||
# series. That way, we can stop having to manually label them all as they
|
||||
# come out and get modeled (TNT is prolific rn!), but we aren't gonna get too
|
||||
# greedy and forget about this and use Nostalgic for some far-future thing,
|
||||
# in ways that will certainly be fixable but would also be confusing and
|
||||
# embarrassing.
|
||||
NOSTALGIC_FINAL_DAY = Date.new(2024, 12, 31)
|
||||
def infer_series_name
|
||||
if !has_real_series_name? && Date.today <= NOSTALGIC_FINAL_DAY
|
||||
self.series_name = "Nostalgic"
|
||||
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
|
||||
|
|
@ -93,7 +85,7 @@ class AltStyle < ApplicationRecord
|
|||
)
|
||||
DEFAULT_THUMBNAIL_URL = "https://images.neopets.com/items/mall_bg_circle.gif"
|
||||
def infer_thumbnail_url
|
||||
if real_series_name?
|
||||
if has_real_series_name?
|
||||
self.thumbnail_url = THUMBNAIL_URL_TEMPLATE.expand(
|
||||
series: series_name.gsub(/\s+/, '').downcase,
|
||||
color: color.name.gsub(/\s+/, '').downcase,
|
||||
|
|
@ -104,28 +96,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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -161,7 +161,7 @@ class AuthUser < AuthRecord
|
|||
# means we can wrap it in a `with_timeout` block!)
|
||||
neopets_username = Sync do |task|
|
||||
task.with_timeout(5) do
|
||||
Neopets::NeoPass.load_main_neopets_username(auth.credentials.token)
|
||||
NeoPass.load_main_neopets_username(auth.credentials.token)
|
||||
end
|
||||
rescue Async::TimeoutError
|
||||
nil # If the request times out, just move on!
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
class Color < ApplicationRecord
|
||||
has_many :pet_types
|
||||
has_many :alt_styles
|
||||
|
||||
scope :alphabetical, -> { order(:name) }
|
||||
scope :basic, -> { where(basic: true) }
|
||||
scope :standard, -> { where(standard: true) }
|
||||
scope :nonstandard, -> { where(standard: false) }
|
||||
scope :funny, -> { order(:prank) unless pranks_funny? }
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
|
|
@ -14,23 +14,27 @@ class Color < ApplicationRecord
|
|||
end
|
||||
|
||||
def human_name
|
||||
if name
|
||||
name.split(' ').map { |word| word.capitalize }.join(' ')
|
||||
if prank? && !Color.pranks_funny?
|
||||
unfunny_human_name + ' ' + I18n.translate('colors.prank_suffix')
|
||||
else
|
||||
I18n.translate('colors.default_human_name')
|
||||
unfunny_human_name
|
||||
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],
|
||||
"species_id ASC").first
|
||||
end
|
||||
|
||||
def unfunny_human_name
|
||||
if name
|
||||
name.split(' ').map { |word| word.capitalize }.join(' ')
|
||||
else
|
||||
I18n.translate('colors.default_human_name')
|
||||
end
|
||||
end
|
||||
|
||||
def default_gender_presentation
|
||||
if name.downcase.ends_with? "boy"
|
||||
:masc
|
||||
|
|
@ -41,7 +45,8 @@ class Color < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def self.param_to_id(param)
|
||||
param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id
|
||||
def self.pranks_funny?
|
||||
now = Time.now.in_time_zone('Pacific Time (US & Canada)')
|
||||
now.month == 4 && now.day == 1
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
require "async"
|
||||
require "async/barrier"
|
||||
|
||||
class Item < ApplicationRecord
|
||||
include PrettyParam
|
||||
include Item::Dyeworks
|
||||
|
|
@ -7,34 +10,21 @@ class Item < ApplicationRecord
|
|||
|
||||
SwfAssetType = 'object'
|
||||
|
||||
serialize :cached_compatible_body_ids, coder: Serializers::IntegerSet
|
||||
serialize :cached_occupied_zone_ids, coder: Serializers::IntegerSet
|
||||
|
||||
has_many :closet_hangers
|
||||
has_one :contribution, as: :contributed, inverse_of: :contributed
|
||||
has_one :contribution, :as => :contributed, :inverse_of => :contributed
|
||||
has_one :nc_mall_record
|
||||
has_many :parent_swf_asset_relationships, as: :parent
|
||||
has_many :swf_assets, through: :parent_swf_asset_relationships
|
||||
has_many :parent_swf_asset_relationships, :as => :parent
|
||||
has_many :swf_assets, :through => :parent_swf_asset_relationships
|
||||
belongs_to :dyeworks_base_item, class_name: "Item",
|
||||
default: -> { inferred_dyeworks_base_item }, optional: true
|
||||
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?
|
||||
|
||||
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,25 +60,39 @@ 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)
|
||||
zone_ids = Zone.matching_label(zone_label).map(&:id)
|
||||
|
||||
# NOTE: In searches, this query performs much better using a subquery
|
||||
# instead of joins! This is because, in the joins case, filtering by an
|
||||
# `swf_assets` field but sorting by an `items` field causes the query
|
||||
# planner to only be able to use an index for *one* of them. In this case,
|
||||
# MySQL can use the `swf_assets`.`zone_id` index to get the item IDs for
|
||||
# the subquery, then use the `items`.`name` index to sort them.
|
||||
i = arel_table
|
||||
psa = ParentSwfAssetRelationship.arel_table
|
||||
sa = SwfAsset.arel_table
|
||||
where(
|
||||
ParentSwfAssetRelationship.joins(:swf_asset).
|
||||
where(sa[:zone_id].in(zone_ids)).
|
||||
where(psa[:parent_type].eq("Item")).
|
||||
where(psa[:parent_id].eq(i[:id])).
|
||||
arel.exists
|
||||
)
|
||||
}
|
||||
scope :not_occupies, ->(zone_label) {
|
||||
Zone.matching_label(zone_label).
|
||||
map { |z| not_occupies_zone_id(z.id) }.reduce(all, &:and)
|
||||
}
|
||||
scope :occupies_zone_id, ->(zone_id) {
|
||||
where("FIND_IN_SET(?, cached_occupied_zone_ids) > 0", zone_id)
|
||||
}
|
||||
scope :not_occupies_zone_id, ->(zone_id) {
|
||||
where.not("FIND_IN_SET(?, cached_occupied_zone_ids) > 0", zone_id)
|
||||
zone_ids = Zone.matching_label(zone_label).map(&:id)
|
||||
i = Item.arel_table
|
||||
sa = SwfAsset.arel_table
|
||||
# Querying for "has NO swf_assets matching these zone IDs" is trickier than
|
||||
# the positive case! To do it, we GROUP_CONCAT the zone_ids together for
|
||||
# each item, then use FIND_IN_SET to search the result for each zone ID,
|
||||
# and assert that it must not find a match. (This is uhh, not exactly fast,
|
||||
# so it helps to have other tighter conditions applied first!)
|
||||
# TODO: I feel like this could also be solved with a LEFT JOIN, idk if that
|
||||
# performs any better? In Rails 5+ `left_outer_joins` is built in so!
|
||||
condition = zone_ids.map { 'FIND_IN_SET(?, GROUP_CONCAT(zone_id)) = 0' }.join(' AND ')
|
||||
joins(:swf_assets).group(i[:id]).having(condition, *zone_ids).distinct
|
||||
}
|
||||
scope :restricts, ->(zone_label) {
|
||||
zone_ids = Zone.matching_label(zone_label).map(&:id)
|
||||
|
|
@ -101,12 +105,31 @@ class Item < ApplicationRecord
|
|||
where("NOT (#{condition})", *zone_ids)
|
||||
}
|
||||
scope :fits, ->(body_id) {
|
||||
where("FIND_IN_SET(?, cached_compatible_body_ids) > 0", body_id).
|
||||
or(where("FIND_IN_SET('0', cached_compatible_body_ids) > 0"))
|
||||
joins(:swf_assets).where(swf_assets: {body_id: [body_id, 0]}).distinct
|
||||
}
|
||||
scope :not_fits, ->(body_id) {
|
||||
where.not("FIND_IN_SET(?, cached_compatible_body_ids) > 0", body_id).
|
||||
and(where.not("FIND_IN_SET('0', cached_compatible_body_ids) > 0"))
|
||||
i = Item.arel_table
|
||||
sa = SwfAsset.arel_table
|
||||
# Querying for "has NO swf_assets matching these body IDs" is trickier than
|
||||
# the positive case! To do it, we GROUP_CONCAT the body_ids together for
|
||||
# each item, then use FIND_IN_SET to search the result for the body ID,
|
||||
# and assert that it must not find a match. (This is uhh, not exactly fast,
|
||||
# so it helps to have other tighter conditions applied first!)
|
||||
#
|
||||
# TODO: I feel like this could also be solved with a LEFT JOIN, idk if that
|
||||
# performs any better? In Rails 5+ `left_outer_joins` is built in so!
|
||||
#
|
||||
# NOTE: The `fits` and `not_fits` counts don't perfectly add up to the
|
||||
# total number of items, 5 items aren't accounted for? I'm not going to
|
||||
# bother looking into this, but one thing I notice is items with no assets
|
||||
# somehow would not match either scope in this impl (but LEFT JOIN would!)
|
||||
joins(:swf_assets).group(i[:id]).
|
||||
having(
|
||||
"FIND_IN_SET(?, GROUP_CONCAT(body_id)) = 0 AND " +
|
||||
"FIND_IN_SET(0, GROUP_CONCAT(body_id)) = 0",
|
||||
body_id
|
||||
).
|
||||
distinct
|
||||
}
|
||||
|
||||
def nc_trade_value
|
||||
|
|
@ -118,11 +141,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 +186,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?
|
||||
|
|
@ -272,23 +296,6 @@ class Item < ApplicationRecord
|
|||
restricted_zones + occupied_zones
|
||||
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
|
||||
|
||||
def species_support_ids
|
||||
@species_support_ids_array ||= read_attribute('species_support_ids').split(',').map(&:to_i) rescue nil
|
||||
end
|
||||
|
|
@ -298,92 +305,70 @@ class Item < ApplicationRecord
|
|||
replacement = replacement.join(',') if replacement.is_a?(Array)
|
||||
write_attribute('species_support_ids', replacement)
|
||||
end
|
||||
|
||||
def support_species?(species)
|
||||
species_support_ids.blank? || species_support_ids.include?(species.id)
|
||||
end
|
||||
|
||||
def modeling_hinted_done?
|
||||
modeling_status_hint == "done" || modeling_status_hint == "glitchy"
|
||||
def modeled_body_ids
|
||||
@modeled_body_ids ||= swf_assets.select('DISTINCT body_id').map(&:body_id)
|
||||
end
|
||||
|
||||
def modeled_color_ids
|
||||
# Might be empty if modeled_body_ids is 0. But it's currently not called
|
||||
# in that scenario, so, whatever.
|
||||
@modeled_color_ids ||= PetType.select('DISTINCT color_id').
|
||||
where(body_id: modeled_body_ids).
|
||||
map(&:color_id)
|
||||
end
|
||||
|
||||
def basic_body_ids
|
||||
@basic_body_ids ||= begin
|
||||
basic_color_ids ||= Color.select([:id]).basic.map(&:id)
|
||||
PetType.select('DISTINCT body_id').
|
||||
where(color_id: basic_color_ids).map(&:body_id)
|
||||
end
|
||||
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 modeled_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
|
||||
# it, that indicates a glitched item, but this method chooses to reflect
|
||||
# behavior elsewhere in the app by saying that we can put this item on
|
||||
# anybody. (Heh. Any body.))
|
||||
compatible_body_ids
|
||||
elsif compatible_body_ids.size == 1
|
||||
modeled_body_ids
|
||||
elsif modeled_body_ids.size == 1
|
||||
# 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
|
||||
modeled_body_ids
|
||||
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.
|
||||
# If an item is worn by more than one body, then it must be wearable by
|
||||
# all bodies of the same color. (To my knowledge, anyway. I'm not aware
|
||||
# of any exceptions.) So, let's find those bodies by first finding those
|
||||
# colors.
|
||||
basic_modeled_body_ids, nonbasic_modeled_body_ids = modeled_body_ids.
|
||||
partition { |bi| basic_body_ids.include?(bi) }
|
||||
|
||||
# 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
|
||||
# ID "basic", so we can treat them specially.)
|
||||
compatible_pairs = compatible_pet_types.joins(:color).
|
||||
merge(Color.nonstandard.or(Color.basic)).
|
||||
distinct.pluck(
|
||||
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] ||= []
|
||||
h[body_id] << color_id
|
||||
end
|
||||
output = []
|
||||
if basic_modeled_body_ids.present?
|
||||
output += basic_body_ids
|
||||
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).
|
||||
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.
|
||||
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.
|
||||
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
|
||||
if nonbasic_modeled_body_ids.present?
|
||||
nonbasic_modeled_color_ids = PetType.select('DISTINCT color_id').
|
||||
where(body_id: nonbasic_modeled_body_ids).
|
||||
map(&:color_id)
|
||||
output += PetType.select('DISTINCT body_id').
|
||||
where(color_id: nonbasic_modeled_color_ids).
|
||||
map(&:body_id)
|
||||
end
|
||||
output
|
||||
end
|
||||
end
|
||||
|
||||
def predicted_missing_body_ids
|
||||
@predicted_missing_body_ids ||= predicted_body_ids - compatible_body_ids
|
||||
@predicted_missing_body_ids ||= predicted_body_ids - modeled_body_ids
|
||||
end
|
||||
|
||||
def predicted_missing_standard_body_ids_by_species_id
|
||||
|
|
@ -403,8 +388,9 @@ class Item < ApplicationRecord
|
|||
end
|
||||
|
||||
def predicted_missing_nonstandard_body_pet_types
|
||||
body_ids = predicted_missing_body_ids - PetType.basic_body_ids
|
||||
PetType.joins(:color).where(body_id: body_ids, colors: {standard: false})
|
||||
PetType.joins(:color).
|
||||
where(body_id: predicted_missing_body_ids - basic_body_ids,
|
||||
colors: {standard: false})
|
||||
end
|
||||
|
||||
def predicted_missing_nonstandard_body_ids_by_species_by_color
|
||||
|
|
@ -429,54 +415,22 @@ 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
|
||||
|
||||
def predicted_modeled_ratio
|
||||
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)
|
||||
modeled_body_ids.size.to_f / predicted_body_ids.size
|
||||
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)
|
||||
return cached_compatible_body_ids if use_cached
|
||||
|
||||
def compatible_body_ids
|
||||
swf_assets.map(&:body_id).uniq
|
||||
end
|
||||
|
||||
|
|
@ -623,19 +577,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 +653,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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ class Item
|
|||
)\z
|
||||
}x
|
||||
def inferred_dyeworks_base_item
|
||||
name_match = (name || "").match(DYEWORKS_NAME_PATTERN)
|
||||
name_match = name.match(DYEWORKS_NAME_PATTERN)
|
||||
return nil if name_match.nil?
|
||||
|
||||
Item.find_by_name(name_match["base"])
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -377,7 +367,7 @@ class Item
|
|||
# If the real series name has been set in the database by support
|
||||
# staff, use that for the canonical filter text for this alt style.
|
||||
# Otherwise, represent this alt style by ID.
|
||||
if alt_style.real_series_name?
|
||||
if alt_style.has_real_series_name?
|
||||
series_name = alt_style.series_name.downcase
|
||||
color_name = alt_style.color.name.downcase
|
||||
species_name = alt_style.species.name.downcase
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@ class ParentSwfAssetRelationship < ApplicationRecord
|
|||
belongs_to :parent, :polymorphic => true
|
||||
|
||||
belongs_to :swf_asset
|
||||
|
||||
after_save :update_parent_cached_fields
|
||||
after_destroy :update_parent_cached_fields
|
||||
|
||||
def item=(replacement)
|
||||
self.parent = replacement
|
||||
|
|
@ -19,8 +16,4 @@ class ParentSwfAssetRelationship < ApplicationRecord
|
|||
def pet_state=(replacement)
|
||||
self.parent = replacement
|
||||
end
|
||||
|
||||
def update_parent_cached_fields
|
||||
parent.try(:update_cached_fields)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,20 +1,82 @@
|
|||
require 'rocketamf_extensions/remote_gateway'
|
||||
require 'ostruct'
|
||||
|
||||
class Pet < ApplicationRecord
|
||||
NEOPETS_URL_ORIGIN = ENV['NEOPETS_URL_ORIGIN'] || 'https://www.neopets.com'
|
||||
GATEWAY_URL = NEOPETS_URL_ORIGIN + '/amfphp/gateway.php'
|
||||
GATEWAY = RocketAMFExtensions::RemoteGateway.new(GATEWAY_URL)
|
||||
CUSTOM_PET_SERVICE = GATEWAY.service('CustomPetService')
|
||||
PET_SERVICE = GATEWAY.service('PetService')
|
||||
|
||||
belongs_to :pet_type
|
||||
|
||||
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 = self.class.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 = Pet.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
|
||||
|
|
@ -25,7 +87,6 @@ class Pet < ApplicationRecord
|
|||
pose: self.pet_state.pose,
|
||||
state: self.pet_state.id,
|
||||
objects: self.items.map(&:id),
|
||||
style: self.alt_style ? self.alt_style.id : nil,
|
||||
}.to_query
|
||||
end
|
||||
|
||||
|
|
@ -40,8 +101,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?
|
||||
|
|
@ -60,6 +124,60 @@ class Pet < ApplicationRecord
|
|||
pet
|
||||
end
|
||||
|
||||
# NOTE: Ideally pet requests shouldn't take this long, but Neopets can be
|
||||
# slow sometimes! Since we're on the Falcon server, long timeouts shouldn't
|
||||
# slow down the rest of the request queue, like it used to be in the past.
|
||||
def self.fetch_viewer_data(name, timeout: 10)
|
||||
request = CUSTOM_PET_SERVICE.action('getViewerData').request([name])
|
||||
send_amfphp_request(request).tap do |data|
|
||||
if data[:custom_pet][:name].blank?
|
||||
raise PetNotFound, "Pet #{name.inspect} does not exist"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.fetch_metadata(name, timeout: 10)
|
||||
# If this is an image hash "pet name", it has no metadata.
|
||||
return nil if name.start_with?("@")
|
||||
|
||||
request = PET_SERVICE.action('getPet').request([name])
|
||||
send_amfphp_request(request).tap do |data|
|
||||
if data[:name].blank?
|
||||
raise PetNotFound, "Pet #{name.inspect} does not exist"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Given a pet's name, load its image hash, for use in `pets.neopets.com`
|
||||
# image URLs. (This corresponds to its current biology and items.)
|
||||
def self.fetch_image_hash(name, timeout: 10)
|
||||
# If this is an image hash "pet name", just take off the `@`!
|
||||
return name[1..] if name.start_with?("@")
|
||||
|
||||
metadata = fetch_metadata(name, timeout:)
|
||||
metadata[:hash]
|
||||
end
|
||||
|
||||
class PetNotFound < RuntimeError;end
|
||||
class DownloadError < RuntimeError;end
|
||||
class UnexpectedDataFormat < RuntimeError;end
|
||||
class ModelingDisabled < RuntimeError;end
|
||||
|
||||
private
|
||||
|
||||
# Send an AMFPHP request, re-raising errors as `Pet::DownloadError`.
|
||||
# Return the response body as a `HashWithIndifferentAccess`.
|
||||
def self.send_amfphp_request(request, timeout: 10)
|
||||
begin
|
||||
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
|
||||
raise DownloadError, e.message, e.backtrace
|
||||
end
|
||||
|
||||
HashWithIndifferentAccess.new(response_data)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,30 +1,20 @@
|
|||
class PetState < ApplicationRecord
|
||||
SwfAssetType = 'biology'
|
||||
|
||||
MAIN_POSES = %w(HAPPY_FEM HAPPY_MASC SAD_FEM SAD_MASC SICK_FEM SICK_MASC)
|
||||
|
||||
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
|
||||
delegate :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, -> {
|
||||
|
|
@ -81,25 +71,50 @@ class PetState < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
# TODO: More and more, wanting to refactor poses…
|
||||
def pose=(pose)
|
||||
case pose
|
||||
when "UNKNOWN"
|
||||
label_pose nil, nil, unconverted: nil, labeled: false
|
||||
when "HAPPY_MASC"
|
||||
label_pose 1, false
|
||||
when "HAPPY_FEM"
|
||||
label_pose 1, true
|
||||
when "SAD_MASC"
|
||||
label_pose 2, false
|
||||
when "SAD_FEM"
|
||||
label_pose 2, true
|
||||
when "SICK_MASC"
|
||||
label_pose 4, false
|
||||
when "SICK_FEM"
|
||||
label_pose 4, true
|
||||
when "UNCONVERTED"
|
||||
label_pose nil, nil, unconverted: true
|
||||
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
|
||||
|
||||
|
|
@ -107,75 +122,58 @@ class PetState < ApplicationRecord
|
|||
"#{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)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# A helper for the `pose=` method.
|
||||
def label_pose(mood_id, female, unconverted: false, labeled: true)
|
||||
self.labeled = labeled
|
||||
self.mood_id = mood_id
|
||||
self.female = female
|
||||
self.unconverted = unconverted
|
||||
end
|
||||
|
||||
def self.last_updated_key
|
||||
PetState.maximum(:updated_at)
|
||||
end
|
||||
|
||||
def self.all_supported_poses
|
||||
Rails.cache.fetch("PetState.all_supported_poses #{last_updated_key}") do
|
||||
{}.tap do |h|
|
||||
includes(:pet_type).find_each do |pet_state|
|
||||
h[pet_state.species_id] ||= {}
|
||||
h[pet_state.species_id][pet_state.color_id] ||= []
|
||||
h[pet_state.species_id][pet_state.color_id] << pet_state.pose
|
||||
end
|
||||
|
||||
h.values.map(&:values).flatten(1).each(&:uniq!).each(&:sort!)
|
||||
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
|
||||
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)
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
pet_states.first
|
||||
existing_swf_assets = SwfAsset.biology_assets.includes(:zone).
|
||||
where(remote_id: swf_asset_ids)
|
||||
existing_swf_assets_by_id = {}
|
||||
existing_swf_assets.each do |swf_asset|
|
||||
existing_swf_assets_by_id[swf_asset.remote_id] = swf_asset
|
||||
end
|
||||
existing_relationships_by_swf_asset_id = {}
|
||||
unless pet_state.new_record?
|
||||
pet_state.parent_swf_asset_relationships.each do |relationship|
|
||||
existing_relationships_by_swf_asset_id[relationship.swf_asset_id] = relationship
|
||||
end
|
||||
end
|
||||
pet_state.pet_type = pet_type # save the second case from having to look it up by ID
|
||||
relationships = []
|
||||
info.each do |zone_id, asset_info|
|
||||
if zone_id.present? && asset_info
|
||||
swf_asset_id = asset_info[:part_id].to_i
|
||||
swf_asset = existing_swf_assets_by_id[swf_asset_id]
|
||||
unless swf_asset
|
||||
swf_asset = SwfAsset.new
|
||||
swf_asset.remote_id = swf_asset_id
|
||||
end
|
||||
swf_asset.origin_biology_data = asset_info
|
||||
swf_asset.origin_pet_type = pet_type
|
||||
relationship = existing_relationships_by_swf_asset_id[swf_asset.id]
|
||||
unless relationship
|
||||
relationship ||= ParentSwfAssetRelationship.new
|
||||
relationship.parent = pet_state
|
||||
relationship.swf_asset_id = swf_asset.id
|
||||
end
|
||||
relationship.swf_asset = swf_asset
|
||||
relationships << relationship
|
||||
end
|
||||
end
|
||||
pet_state.parent_swf_asset_relationships_to_update = relationships
|
||||
pet_state
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -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,41 +113,7 @@ class PetType < ApplicationRecord
|
|||
end
|
||||
|
||||
def to_param
|
||||
"#{possibly_new_color.to_param}-#{possibly_new_species.to_param}"
|
||||
end
|
||||
|
||||
def fully_labeled?
|
||||
num_missing_poses == 0
|
||||
end
|
||||
|
||||
def num_poses
|
||||
all_poses = pet_states.map(&:pose)
|
||||
PetState::MAIN_POSES.count { |pose| all_poses.include? pose }
|
||||
end
|
||||
|
||||
def num_missing_poses
|
||||
PetState::MAIN_POSES.count - num_poses
|
||||
end
|
||||
|
||||
def num_unlabeled_states
|
||||
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)
|
||||
"#{color.human_name}-#{species.human_name}"
|
||||
end
|
||||
|
||||
def self.all_by_ids_or_children(ids, pet_states)
|
||||
|
|
@ -179,5 +135,7 @@ class PetType < ApplicationRecord
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
class DownloadError < Exception;end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
require 'addressable/template'
|
||||
require 'async'
|
||||
require 'async/barrier'
|
||||
require 'async/semaphore'
|
||||
require 'fileutils'
|
||||
require 'uri'
|
||||
|
||||
class SwfAsset < ApplicationRecord
|
||||
# We use the `type` column to mean something other than what Rails means!
|
||||
|
|
@ -38,7 +43,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 +188,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 +322,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 +356,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
|
||||
|
|
@ -357,4 +373,6 @@ class SwfAsset < ApplicationRecord
|
|||
# linked to it, meaning that it's probably wearable by all bodies.
|
||||
self.body_id = 0 if !@body_id_overridden && (!self.body_specific? || (!self.new_record? && self.body_id_changed?))
|
||||
end
|
||||
|
||||
class DownloadError < Exception;end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
126
app/services/nc_mall.rb
Normal file
126
app/services/nc_mall.rb
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
require "addressable/template"
|
||||
require "async/http/internet/instance"
|
||||
|
||||
module NCMall
|
||||
# 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/load_page.phtml?lang=en{&type,cat}"
|
||||
)
|
||||
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
|
||||
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})"
|
||||
end
|
||||
|
||||
parse_nc_page response.read
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# 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)
|
||||
rescue JSON::ParserError
|
||||
Rails.logger.debug "Unexpected NC page response:\n#{nc_page_str}"
|
||||
raise UnexpectedResponseFormat,
|
||||
"failed to parse NC page response as JSON"
|
||||
end
|
||||
|
||||
unless nc_page.has_key? "object_data"
|
||||
raise UnexpectedResponseFormat, "missing field object_data in NC page"
|
||||
end
|
||||
|
||||
object_data = nc_page["object_data"]
|
||||
|
||||
# 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"],
|
||||
description: item_info["description"],
|
||||
price: item_info["price"],
|
||||
discount: parse_item_discount(item_info),
|
||||
is_available: item_info["isAvailable"] == 1,
|
||||
}
|
||||
end
|
||||
|
||||
{items:}
|
||||
end
|
||||
|
||||
# Given item info, return a hash of discount-specific info, if any.
|
||||
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: item_info["discountBegin"],
|
||||
ends_at: item_info["discountEnd"],
|
||||
}
|
||||
end
|
||||
|
||||
class ResponseNotOK < StandardError
|
||||
attr_reader :status
|
||||
def initialize(status)
|
||||
super
|
||||
@status = status
|
||||
end
|
||||
end
|
||||
class UnexpectedResponseFormat < StandardError;end
|
||||
end
|
||||
|
|
@ -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
|
||||
module 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})"
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
require 'rocketamf_extensions/remote_gateway'
|
||||
|
||||
module Neopets::CustomPets
|
||||
GATEWAY_URL =
|
||||
Addressable::URI.parse(Rails.configuration.neopets_origin) +
|
||||
'/amfphp/gateway.php'
|
||||
GATEWAY = RocketAMFExtensions::RemoteGateway.new(GATEWAY_URL)
|
||||
CUSTOM_PET_SERVICE = GATEWAY.service('CustomPetService')
|
||||
PET_SERVICE = GATEWAY.service('PetService')
|
||||
|
||||
class << self
|
||||
# NOTE: Ideally pet requests shouldn't take this long, but Neopets can be
|
||||
# slow sometimes! Since we're on the Falcon server, long timeouts shouldn't
|
||||
# slow down the rest of the request queue, like it used to be in the past.
|
||||
def fetch_viewer_data(name, timeout: 10)
|
||||
request = CUSTOM_PET_SERVICE.action('getViewerData').request([name])
|
||||
send_amfphp_request(request).tap do |data|
|
||||
if data[:custom_pet][:name].blank?
|
||||
raise PetNotFound, "Pet #{name.inspect} does not exist"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_metadata(name, timeout: 10)
|
||||
# If this is an image hash "pet name", it has no metadata.
|
||||
return nil if name.start_with?("@")
|
||||
|
||||
request = PET_SERVICE.action('getPet').request([name])
|
||||
send_amfphp_request(request).tap do |data|
|
||||
if data[:name].blank?
|
||||
raise PetNotFound, "Pet #{name.inspect} does not exist"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Given a pet's name, load its image hash, for use in `pets.neopets.com`
|
||||
# image URLs. (This corresponds to its current biology and items.)
|
||||
def fetch_image_hash(name, timeout: 10)
|
||||
# If this is an image hash "pet name", just take off the `@`!
|
||||
return name[1..] if name.start_with?("@")
|
||||
|
||||
metadata = fetch_metadata(name, timeout:)
|
||||
metadata[:hash]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Send an AMFPHP request, re-raising errors as `DownloadError`.
|
||||
# Return the response body as a `HashWithIndifferentAccess`.
|
||||
def send_amfphp_request(request, timeout: 10)
|
||||
begin
|
||||
response_data = request.post(timeout: timeout)
|
||||
rescue RocketAMFExtensions::RemoteGateway::AMFError => e
|
||||
raise DownloadError, e.message
|
||||
rescue RocketAMFExtensions::RemoteGateway::ConnectionError => e
|
||||
raise DownloadError, e.message, e.backtrace
|
||||
end
|
||||
|
||||
HashWithIndifferentAccess.new(response_data)
|
||||
end
|
||||
end
|
||||
|
||||
class PetNotFound < RuntimeError;end
|
||||
class DownloadError < RuntimeError;end
|
||||
end
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
require "addressable/template"
|
||||
|
||||
# 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.
|
||||
CATEGORY_PAGE_URL_TEMPLATE = Addressable::Template.new(
|
||||
"https://ncmall.neopets.com/mall/ajax/v2/category/index.phtml{?type,cat,page,limit}"
|
||||
)
|
||||
def self.load_page(type, cat, page: 1, limit: 24)
|
||||
url = CATEGORY_PAGE_URL_TEMPLATE.expand(type:, cat:, page:, limit:)
|
||||
Sync do
|
||||
DTIRequests.get(url) do |response|
|
||||
if response.status != 200
|
||||
raise ResponseNotOK.new(response.status),
|
||||
"expected status 200 but got #{response.status} (#{url})"
|
||||
end
|
||||
|
||||
parse_nc_page response.read
|
||||
end
|
||||
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!
|
||||
def self.parse_nc_page(nc_page_str)
|
||||
begin
|
||||
nc_page = JSON.parse(nc_page_str)
|
||||
rescue JSON::ParserError
|
||||
Rails.logger.debug "Unexpected NC page response:\n#{nc_page_str}"
|
||||
raise UnexpectedResponseFormat,
|
||||
"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"
|
||||
end
|
||||
|
||||
item_data = nc_page["data"] || []
|
||||
|
||||
items = item_data.map do |item_info|
|
||||
{
|
||||
id: item_info["id"],
|
||||
name: item_info["name"],
|
||||
description: item_info["description"],
|
||||
price: item_info["price"],
|
||||
discount: parse_item_discount(item_info),
|
||||
is_available: item_info["isAvailable"] == 1,
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
items:,
|
||||
total_pages: nc_page["totalPages"].to_i,
|
||||
page: nc_page["page"].to_i,
|
||||
limit: nc_page["limit"].to_i,
|
||||
}
|
||||
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"]),
|
||||
}
|
||||
end
|
||||
|
||||
class ResponseNotOK < StandardError
|
||||
attr_reader :status
|
||||
def initialize(status)
|
||||
super
|
||||
@status = status
|
||||
end
|
||||
end
|
||||
class UnexpectedResponseFormat < StandardError;end
|
||||
end
|
||||
|
|
@ -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})"
|
||||
|
|
|
|||
77
app/services/owls_value_guide.rb
Normal file
77
app/services/owls_value_guide.rb
Normal 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
|
||||
|
|
@ -1,12 +1,4 @@
|
|||
%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
|
||||
.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
|
||||
%li.alt-style
|
||||
= link_to alt_style.preview_image_url do
|
||||
= image_tag alt_style.thumbnail_url, class: 'alt-style-thumbnail'
|
||||
.alt-style-name= alt_style.name
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
- title @alt_style.full_name
|
||||
- use_responsive_design
|
||||
|
||||
%ol.breadcrumbs
|
||||
%li= link_to "Alt Styles", alt_styles_path
|
||||
%li
|
||||
= link_to @alt_style.color.human_name,
|
||||
alt_styles_path(color: @alt_style.color.human_name)
|
||||
%li{"data-relation-to-prev": "sibling"}
|
||||
= link_to @alt_style.species.human_name,
|
||||
alt_styles_path(species: @alt_style.species.human_name)
|
||||
%li= @alt_style.series_name
|
||||
|
||||
= 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
|
||||
= 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"
|
||||
Then: Go to unlabeled style
|
||||
|
||||
- content_for :stylesheets do
|
||||
= stylesheet_link_tag "application/breadcrumbs", "application/support-form"
|
||||
= page_stylesheet_link_tag "alt_styles/edit"
|
||||
|
|
@ -1,57 +1,18 @@
|
|||
- title "NC Pet Styles"
|
||||
- use_responsive_design
|
||||
- title "Styling Studio"
|
||||
|
||||
%ul.breadcrumbs
|
||||
%li= link_to "Rainbow Pool", pet_types_path
|
||||
%li Pet Styles
|
||||
%p
|
||||
Here's all the new NC Pet Styles we have! They're available in the app too,
|
||||
by opening the emotion picker and clicking the "Styles" tab.
|
||||
|
||||
:markdown
|
||||
Pet Styles drastically change the appearance of your pet! They're [available
|
||||
in the NC Mall][1], or via "NC Trading". Some of them are "Nostalgic",
|
||||
meaning they're reminiscent of classic Neopets designs from long ago—and some
|
||||
are brand new!
|
||||
|
||||
Pet Styles only fit pets of the same species—but the *color* of the pet
|
||||
doesn't matter! A Blue Acara can wear the "Nostalgic Faerie Acara" Pet Style.
|
||||
|
||||
Only some items fit pets wearing Pet Styles: mostly Backgrounds, Foregrounds,
|
||||
and other items that aren't designed to fit a specific body shape.
|
||||
|
||||
If you have a Pet Style we don't, please model it by entering your pet's
|
||||
%p
|
||||
If you have an Alt Style we don't, please model it by entering your pet's
|
||||
name on the homepage! Thank you! 💖
|
||||
|
||||
[1]: https://www.neopets.com/mall/stylingstudio/
|
||||
%p
|
||||
Also, heads-up: Because our system can only collect "item data" for normal
|
||||
wearable items, there's not a great way for us to get style tokens onto
|
||||
tradelists… this may change someday, but probably not soon, sorry!
|
||||
|
||||
- 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
|
||||
%legend Filter by:
|
||||
= f.select :series, @all_series_names,
|
||||
selected: @series_name, include_blank: "Style…"
|
||||
= f.select :color, @all_color_names,
|
||||
selected: @color&.human_name, include_blank: "Color…"
|
||||
= f.select :species, @all_species_names,
|
||||
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.
|
||||
|
||||
- content_for :stylesheets do
|
||||
= stylesheet_link_tag "application/breadcrumbs"
|
||||
= stylesheet_link_tag "application/rainbow-pool"
|
||||
= page_stylesheet_link_tag "alt_styles/index"
|
||||
- @alt_styles.group_by(&:species).each do |species, species_styles|
|
||||
%h2.alt-styles-header= species.human_name
|
||||
%ul.alt-styles-list= render partial: "alt_style", collection: species_styles
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
- html_options = {} unless defined? html_options
|
||||
= content_tag "outfit-viewer", **html_options do
|
||||
%outfit-viewer
|
||||
.loading-indicator= render partial: "hanger_spinner"
|
||||
|
||||
%label.play-pause-button{title: "Pause/play animations"}
|
||||
|
|
@ -21,9 +20,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}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue