Compare commits

..

No commits in common. "main" and "rainbow-pool" have entirely different histories.

140 changed files with 899 additions and 4790 deletions

2
.gitignore vendored
View file

@ -4,8 +4,6 @@ log/*.log
tmp/**/* tmp/**/*
.env .env
.env.* .env.*
/spec/examples.txt
/.yardoc
/app/assets/builds/* /app/assets/builds/*
!/app/assets/builds/.keep !/app/assets/builds/.keep

View file

@ -1,5 +1,4 @@
#!/usr/bin/env sh #!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh" . "$(dirname -- "$0")/_/husky.sh"
# Run the linter, and all our tests. yarn lint --max-warnings=0 --fix
yarn lint --max-warnings=0 --fix && bin/rake test spec

1
.rspec
View file

@ -1 +0,0 @@
--require spec_helper

19
Gemfile
View file

@ -19,7 +19,7 @@ gem 'haml', '~> 6.1', '>= 6.1.1'
gem 'sass-rails', '~> 6.0' gem 'sass-rails', '~> 6.0'
gem 'terser', '~> 1.1', '>= 1.1.17' gem 'terser', '~> 1.1', '>= 1.1.17'
gem 'react-rails', '~> 2.7', '>= 2.7.1' gem 'react-rails', '~> 2.7', '>= 2.7.1'
gem 'jsbundling-rails', '~> 1.3' gem 'jsbundling-rails', '~> 1.1'
gem 'turbo-rails', '~> 2.0' gem 'turbo-rails', '~> 2.0'
# For authentication. # For authentication.
@ -66,10 +66,7 @@ gem "async-http", "~> 0.75.0", require: false
gem "thread-local", "~> 1.1", require: false gem "thread-local", "~> 1.1", require: false
# For debugging. # For debugging.
group :development do gem 'web-console', '~> 4.2', group: :development
gem 'debug', '~> 1.9.2'
gem 'web-console', '~> 4.2'
end
# Reduces boot times through caching; required in config/boot.rb # Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '~> 1.16', require: false gem 'bootsnap', '~> 1.16', require: false
@ -87,13 +84,5 @@ gem "sentry-rails", "~> 5.12"
gem "shell", "~> 0.8.1" gem "shell", "~> 0.8.1"
# For workspace autocomplete. # For workspace autocomplete.
group :development do gem "solargraph", "~> 0.50.0", group: :development
gem "solargraph", "~> 0.50.0" gem "solargraph-rails", "~> 1.1", group: :development
gem "solargraph-rails", "~> 1.1"
end
# For automated tests.
group :development, :test do
gem "rspec-rails", "~> 7.0"
gem "webmock", "~> 3.24", group: :test
end

View file

@ -128,15 +128,9 @@ GEM
fiber-annotation fiber-annotation
fiber-local (~> 1.1) fiber-local (~> 1.1)
json json
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6) crass (1.0.6)
csv (3.3.0) csv (3.3.0)
date (3.3.4) date (3.3.4)
debug (1.9.2)
irb (~> 1.10)
reline (>= 0.3.8)
devise (4.9.4) devise (4.9.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
@ -188,7 +182,6 @@ GEM
temple (>= 0.8.2) temple (>= 0.8.2)
thor thor
tilt tilt
hashdiff (1.1.2)
hashie (5.0.0) hashie (5.0.0)
http_accept_language (2.1.1) http_accept_language (2.1.1)
httparty (0.22.0) httparty (0.22.0)
@ -383,23 +376,6 @@ GEM
reverse_markdown (2.1.1) reverse_markdown (2.1.1)
nokogiri nokogiri
rexml (3.3.7) rexml (3.3.7)
rspec-core (3.13.2)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.0.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.1)
rubocop (1.66.1) rubocop (1.66.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
@ -503,10 +479,6 @@ GEM
activesupport activesupport
faraday (~> 2.0) faraday (~> 2.0)
faraday-follow_redirects faraday-follow_redirects
webmock (3.24.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.2) webrick (1.8.2)
websocket-driver (0.7.6) websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
@ -524,7 +496,6 @@ DEPENDENCIES
async (~> 2.17) async (~> 2.17)
async-http (~> 0.75.0) async-http (~> 0.75.0)
bootsnap (~> 1.16) bootsnap (~> 1.16)
debug (~> 1.9.2)
devise (~> 4.9, >= 4.9.2) devise (~> 4.9, >= 4.9.2)
devise-encryptable (~> 0.2.0) devise-encryptable (~> 0.2.0)
dotenv-rails (~> 2.8, >= 2.8.1) dotenv-rails (~> 2.8, >= 2.8.1)
@ -532,7 +503,7 @@ DEPENDENCIES
haml (~> 6.1, >= 6.1.1) haml (~> 6.1, >= 6.1.1)
http_accept_language (~> 2.1, >= 2.1.1) http_accept_language (~> 2.1, >= 2.1.1)
httparty (~> 0.22.0) httparty (~> 0.22.0)
jsbundling-rails (~> 1.3) jsbundling-rails (~> 1.1)
letter_opener (~> 1.8, >= 1.8.1) letter_opener (~> 1.8, >= 1.8.1)
memory_profiler (~> 1.0) memory_profiler (~> 1.0)
mysql2 (~> 0.5.5) mysql2 (~> 0.5.5)
@ -547,7 +518,6 @@ DEPENDENCIES
rails-i18n (~> 7.0, >= 7.0.7) rails-i18n (~> 7.0, >= 7.0.7)
rdiscount (~> 2.2, >= 2.2.7.1) rdiscount (~> 2.2, >= 2.2.7.1)
react-rails (~> 2.7, >= 2.7.1) react-rails (~> 2.7, >= 2.7.1)
rspec-rails (~> 7.0)
sanitize (~> 6.0, >= 6.0.2) sanitize (~> 6.0, >= 6.0.2)
sass-rails (~> 6.0) sass-rails (~> 6.0)
sentry-rails (~> 5.12) sentry-rails (~> 5.12)
@ -561,7 +531,6 @@ DEPENDENCIES
thread-local (~> 1.1) thread-local (~> 1.1)
turbo-rails (~> 2.0) turbo-rails (~> 2.0)
web-console (~> 4.2) web-console (~> 4.2)
webmock (~> 3.24)
will_paginate (~> 4.0) will_paginate (~> 4.0)
RUBY VERSION RUBY VERSION

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -81,35 +81,23 @@ class SpeciesFacePickerOptions extends HTMLElement {
} }
} }
// TODO: If it ever gets wide support, remove this in favor of the CSS rule class MeasuredContent extends HTMLElement {
// `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"];
connectedCallback() { connectedCallback() {
setTimeout(() => this.#measure(), 0); 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() { #measure() {
// Find our `<measured-content>` child, and set our natural width as // Find our `<measured-container>` parent, and set our natural width
// `var(--natural-width)` in the context of our CSS styles. // as `var(--natural-width)` in the context of its CSS styles.
const content = this.querySelector("measured-content"); const container = this.closest("measured-container");
if (content == null) { if (container == null) {
throw new Error(`<measured-container> must contain a <measured-content>`); 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-color-picker", SpeciesColorPicker);
customElements.define("species-face-picker", SpeciesFacePicker); customElements.define("species-face-picker", SpeciesFacePicker);
customElements.define("species-face-picker-options", SpeciesFacePickerOptions); customElements.define("species-face-picker-options", SpeciesFacePickerOptions);
customElements.define("measured-container", MeasuredContainer); customElements.define("measured-content", MeasuredContent);

View file

@ -1,33 +0,0 @@
class MagicMagnifier extends HTMLElement {
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");
}
}
class MagicMagnifierLens extends HTMLElement {
useContent(contentNodes) {
for (const contentNode of contentNodes) {
this.appendChild(contentNode.cloneNode(true));
}
}
}
customElements.define("magic-magnifier", MagicMagnifier);
customElements.define("magic-magnifier-lens", MagicMagnifierLens);

View file

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

View file

@ -74,7 +74,7 @@ $container_width: 800px
input, button, select, label input, button, select, label
cursor: pointer 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 border-radius: 3px
background: #fff background: #fff
border: 1px solid $input-border-color border: 1px solid $input-border-color
@ -83,15 +83,6 @@ input[type=text], input[type=password], input[type=search], input[type=number],
&:focus, &:active &:focus, &:active
color: inherit color: inherit
select:has(option[value='']:checked)
color: #666
option[value='']
color: #666
option:not([value=''])
color: $text-color
textarea textarea
font: inherit font: inherit

View file

@ -3,20 +3,10 @@ body.use-responsive-design
max-width: 100% max-width: 100%
padding-inline: 1rem padding-inline: 1rem
box-sizing: border-box box-sizing: border-box
padding-top: 0
#main-nav
display: flex
flex-wrap: wrap
#home-link, #userbar
position: static
#home-link #home-link
padding-inline: .5rem margin-left: 1rem
margin-inline: -.5rem padding-inline: 0
margin-right: auto
#userbar #userbar
margin-left: auto margin-right: 1rem
text-align: right

View 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

View file

@ -1,4 +0,0 @@
.alt-style-preview
width: 300px
height: 300px
margin: 0 auto

View file

@ -1,3 +0,0 @@
.rainbow-pool-list
.name span
display: inline-block

View file

@ -8,6 +8,7 @@
@import partials/jquery.jgrowl @import partials/jquery.jgrowl
@import alt_styles/index
@import closet_hangers/index @import closet_hangers/index
@import closet_lists/form @import closet_lists/form
@import neopets_page_import_tasks/new @import neopets_page_import_tasks/new

View file

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

View file

@ -1,47 +0,0 @@
magic-magnifier
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
&:hover
@container style(--magic-magnifier-x) and style(--magic-magnifier-y)
magic-magnifier-lens
display: block
magic-magnifier-lens
width: var(--magic-magnifier-lens-width, 100px)
height: var(--magic-magnifier-lens-height, 100px)
overflow: hidden
border-radius: 100%
background: white
border: 2px solid black
box-shadow: 3px 3px 3px rgba(0, 0, 0, .5)
position: absolute
left: var(--magic-magnifier-x, 0px)
top: var(--magic-magnifier-y, 0px)
> *
// Translations are applied in the opposite of the order they're specified.
// So, here's what we're doing:
//
// 1. Translate the content left by --magic-magnifier-x and up by
// --magic-magnifier-y, to align the target location with the lens's
// top-right corner.
// 2. Zoom in by --magic-magnifier-scale.
// 3. Translate the content right by half of --magic-magnifier-lens-width,
// and down by half of --magic-magnifier-lens-height, to align the
// target location with the lens's center.
//
// Note that it *is* possible to specify transforms relative to the center,
// rather than the top-left cornerthis is in fact the default!but that
// gets confusing fast with scale in play. I think this is easier to reason
// about with the top-left corner in terms of math, and center it after the
// fact.
transform: translateX(calc(var(--magic-magnifier-lens-width, 100px) / 2)) translateY(calc(var(--magic-magnifier-lens-height, 100px) / 2)) scale(var(--magic-magnifier-scale, 2)) translateX(calc(-1 * var(--magic-magnifier-x, 0px))) translateY(calc(-1 * var(--magic-magnifier-y, 0px)))
transform-origin: left top

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
outfit-viewer
margin: 0 auto
dt
cursor: help

View file

@ -1,78 +0,0 @@
@import "../partials/clean/constants"
@import "../partials/context_button"
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
[type=submit]
+context-button
> table
flex: 0 0 auto
border-collapse: collapse
table-layout: fixed
border-radius: .5em
th, td
border: 1px solid $module-border-color
font-size: .85em
padding: .25em .5em
text-align: left
> tbody
[data-field=links]
ul
list-style-type: none
display: flex
gap: .5em
// Once the component is ready, add some hints about potential interactions.
&:state(ready)
> table
> tbody > tr
cursor: zoom-in
&:hover
background: $module-bg-color
magic-magnifier
--magic-magnifier-lens-width: 100px
--magic-magnifier-lens-height: 100px
--magic-magnifier-scale: 2.5
magic-magnifier-lens
z-index: 2 // Be above things by default, but not by much!

View file

@ -1,8 +1,6 @@
@import "../partials/clean/constants" @import "../partials/clean/constants"
.rainbow-pool-filters .pet-filters
margin-block: .5em
fieldset fieldset
display: flex display: flex
flex-direction: row flex-direction: row
@ -14,20 +12,19 @@
display: contents display: contents
font-weight: bold font-weight: bold
select [role=navigation]
width: 16ch margin-block: .5em
text-align: center
.rainbow-pool-list .pet-types
list-style-type: none list-style-type: none
display: flex display: flex
flex-wrap: wrap flex-wrap: wrap
justify-content: center justify-content: center
gap: .5em gap: .5em
--preview-base-width: 150px
> li > li
width: var(--preview-base-width) width: 150px
max-width: calc(50% - .25em) max-width: calc(50% - .25em)
min-width: 150px min-width: 150px
box-sizing: border-box box-sizing: border-box
@ -43,7 +40,7 @@
outline: 1px solid $module-border-color outline: 1px solid $module-border-color
background: $module-bg-color background: $module-bg-color
.preview img
width: 100% width: 100%
height: auto height: auto
aspect-ratio: 1 / 1 aspect-ratio: 1 / 1
@ -56,19 +53,3 @@
margin: 0 auto margin: 0 auto
position: relative position: relative
z-index: 1 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

View file

@ -1,8 +1,43 @@
@import "../partials/clean/constants" @import "../partials/clean/constants"
.rainbow-pool-list .pet-states
--preview-base-width: 200px list-style-type: none
margin-bottom: 2em 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 .glitched
cursor: help cursor: help

View file

@ -1,40 +1,21 @@
class AltStylesController < ApplicationController class AltStylesController < ApplicationController
before_action :support_staff_only, except: [:index]
def index def index
@all_alt_styles = AltStyle.includes(:species, :color) @alt_styles = AltStyle.includes(:species, :color, :swf_assets).
order(:species_id, :color_id)
@all_colors = @all_alt_styles.map(&:color).uniq.sort_by(&:name) if params[:species_id]
@all_species = @all_alt_styles.map(&:species).uniq.sort_by(&:name) @species = Species.find(params[:species_id])
@alt_styles = @alt_styles.merge(@species.alt_styles)
end
@all_series_names = @all_alt_styles.map(&:series_name).uniq.sort # We're going to link to the HTML5 image URL, so make sure we have all the
@all_color_names = @all_colors.map(&:human_name)
@all_species_names = @all_species.map(&:human_name)
@series_name = params[:series]
@color = find_color
@species = find_species
@alt_styles = @all_alt_styles.includes(: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 using the HTML5 image for our preview, so make sure we have all the
# manifests ready! # manifests ready!
SwfAsset.preload_manifests @alt_styles.map(&:swf_assets).flatten SwfAsset.preload_manifests @alt_styles.map(&:swf_assets).flatten
respond_to do |format| respond_to do |format|
format.html { format.html { render }
@alt_styles = @alt_styles.
by_creation_date.order(:color_id, :species_id, :series_name).
paginate(page: params[:page], per_page: 30)
render
}
format.json { format.json {
@alt_styles = @alt_styles.includes(swf_assets: [:zone]). render json: @alt_styles.includes(swf_assets: [:zone]).as_json(
sort_by(&:full_name)
render json: @alt_styles.as_json(
only: [:id, :species_id, :color_id, :body_id, :series_name, only: [:id, :species_id, :color_id, :body_id, :series_name,
:adjective_name, :thumbnail_url], :adjective_name, :thumbnail_url],
include: { include: {
@ -49,56 +30,4 @@ class AltStylesController < ApplicationController
} }
end end
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, :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 end

View file

@ -4,7 +4,7 @@ require 'async/container'
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
protect_from_forgery protect_from_forgery
helper_method :current_user, :support_staff?, :user_signed_in? helper_method :current_user, :user_signed_in?
before_action :set_locale before_action :set_locale
@ -110,13 +110,5 @@ class ApplicationController < ActionController::Base
Rails.logger.debug "Using return_to path: #{return_to.inspect}" Rails.logger.debug "Using return_to path: #{return_to.inspect}"
return_to || root_path return_to || root_path
end end
def support_staff?
current_user&.support_staff?
end
def support_staff_only
raise AccessDenied, "Support staff only" unless support_staff?
end
end end

View file

@ -1,6 +1,5 @@
class ItemsController < ApplicationController class ItemsController < ApplicationController
before_action :set_query before_action :set_query
before_action :support_staff_only, except: [:index, :show, :sources]
rescue_from Item::Search::Error, :with => :search_error rescue_from Item::Search::Error, :with => :search_error
def index def index
@ -113,21 +112,6 @@ class ItemsController < ApplicationController
end end
end end
def edit
@item = Item.find params[:id]
render layout: "application"
end
def update
@item = Item.find params[:id]
if @item.update(item_params)
flash[:notice] = "\"#{@item.name}\" successfully saved!"
redirect_to @item
else
render action: "edit", layout: "application", status: :bad_request
end
end
def sources def sources
# Load all the items, then group them by source. # Load all the items, then group them by source.
item_ids = params[:ids].split(",") item_ids = params[:ids].split(",")
@ -180,15 +164,6 @@ class ItemsController < ApplicationController
protected protected
def item_params
params.require(:item).permit(
:name, :thumbnail_url, :description, :modeling_status_hint,
:is_manually_nc, :explicitly_body_specific,
).tap do |p|
p[:modeling_status_hint] = nil if p[:modeling_status_hint] == ""
end
end
def assign_closeted!(items) def assign_closeted!(items)
current_user.assign_closeted_to_items!(items) if user_signed_in? current_user.assign_closeted_to_items!(items) if user_signed_in?
end end

View file

@ -47,24 +47,29 @@ class OutfitsController < ApplicationController
end end
def new def new
@colors = Color.alphabetical @colors = Color.funny.alphabetical
@species = Species.alphabetical @species = Species.alphabetical
newest_items = Item.newest.limit(18) # HACK: Skip this in development, because it's slow!
@newest_modeled_items, @newest_unmodeled_items = unless Rails.env.development?
newest_items.partition(&:predicted_fully_modeled?) 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_missing_species_by_color = {}
@newest_unmodeled_items_predicted_modeled_ratio = {} @newest_unmodeled_items_predicted_modeled_ratio = {}
@newest_unmodeled_items.each do |item| @newest_unmodeled_items.each do |item|
h = item.predicted_missing_nonstandard_body_ids_by_species_by_color h = item.predicted_missing_nonstandard_body_ids_by_species_by_color
standard_body_ids_by_species = item. standard_body_ids_by_species = item.
predicted_missing_standard_body_ids_by_species predicted_missing_standard_body_ids_by_species
if standard_body_ids_by_species.present? if standard_body_ids_by_species.present?
h[:standard] = standard_body_ids_by_species 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 end
@newest_unmodeled_items_predicted_missing_species_by_color[item] = h
@newest_unmodeled_items_predicted_modeled_ratio[item] = item.predicted_modeled_ratio
end end
@species_count = Species.count @species_count = Species.count

View file

@ -1,56 +1,6 @@
class PetStatesController < ApplicationController class PetStatesController < ApplicationController
before_action :support_staff_only def show
before_action :find_pet_state @pet_type = PetType.matching_name_param(params[:pet_type_name]).first!
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])
@pet_state = @pet_type.pet_states.find(params[:id]) @pet_state = @pet_type.pet_states.find(params[:id])
@reference_pet_type = @pet_type.reference
end
def preload_assets
SwfAsset.preload_manifests @pet_state.swf_assets
end
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
end end

View file

@ -1,61 +1,27 @@
class PetTypesController < ApplicationController class PetTypesController < ApplicationController
def index def index
respond_to do |format| @species_names = Species.order(:name).map(&:human_name)
format.html { @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? if params[:species].present?
@selected_species = Species.find_by!(name: params[:species]) @selected_species = Species.find_by!(name: params[:species])
@selected_species_name = @selected_species.human_name @selected_species_name = @selected_species.human_name
end end
if params[:color].present? if params[:color].present?
@selected_color = Color.find_by!(name: params[:color]) @selected_color = Color.find_by!(name: params[:color])
@selected_color_name = @selected_color.human_name @selected_color_name = @selected_color.human_name
end end
@selected_order =
if @selected_species.present? || @selected_color.present?
:alphabetical
else
:newest
end
@pet_types = PetType. @pet_types = PetType.
includes(:color, :species, :pet_states). includes(:color, :species).
paginate(page: params[:page], per_page: 30) order(created_at: :desc).
paginate(page: params[:page], per_page: 30)
@pet_types.where!(species_id: @selected_species) if @selected_species if @selected_species
@pet_types.where!(color_id: @selected_color) if @selected_color @pet_types = @pet_types.where(species_id: @selected_species)
if @selected_order == :newest end
@pet_types.order!(created_at: :desc) if @selected_color
elsif @selected_order == :alphabetical @pet_types = @pet_types.where(color_id: @selected_color)
@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
}
end end
end end
@ -80,7 +46,9 @@ class PetTypesController < ApplicationController
color_id: params[:color_id], color_id: params[:color_id],
) )
elsif params[:name] elsif params[:name]
PetType.find_by_param!(params[:name]) color_name, _, species_name = params[:name].rpartition("-")
raise ActiveRecord::RecordNotFound if species_name.blank?
PetType.matching_name(color_name, species_name).first!
else else
raise "expected params: species_id and color_id, or name" raise "expected params: species_id and color_id, or name"
end end
@ -91,12 +59,11 @@ class PetTypesController < ApplicationController
# #
# If no main poses are available, then we just make all the poses # If no main poses are available, then we just make all the poses
# "canonical", and show the whole mish-mash! # "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) def group_pet_states(pet_states)
pose_groups = pet_states.emotion_order.group_by(&:pose) pose_groups = pet_states.emotion_order.group_by(&:pose)
main_groups = main_groups = pose_groups.select { |k| MAIN_POSES.include?(k) }.values
pose_groups.select { |k| PetState::MAIN_POSES.include?(k) }.values other_groups = pose_groups.reject { |k| MAIN_POSES.include?(k) }.values
other_groups =
pose_groups.reject { |k| PetState::MAIN_POSES.include?(k) }.values
if main_groups.empty? if main_groups.empty?
return {canonical: other_groups.flatten(1).sort_by(&:pose), other: []} return {canonical: other_groups.flatten(1).sort_by(&:pose), other: []}

View file

@ -1,11 +1,14 @@
class PetsController < ApplicationController class PetsController < ApplicationController
rescue_from Neopets::CustomPets::PetNotFound, with: :pet_not_found rescue_from Pet::PetNotFound, with: :pet_not_found
rescue_from Neopets::CustomPets::DownloadError, with: :pet_download_error rescue_from PetType::DownloadError, SwfAsset::DownloadError, with: :asset_download_error
rescue_from Pet::ModelingDisabled, with: :modeling_disabled rescue_from Pet::DownloadError, with: :pet_download_error
rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format
def load 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]) @pet = Pet.load(params[:name])
points = contribute(current_user, @pet) points = contribute(current_user, @pet)
@ -45,6 +48,12 @@ class PetsController < ApplicationController
:status => :not_found :status => :not_found
end 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) def pet_download_error(e)
Rails.logger.warn e.message Rails.logger.warn e.message
Rails.logger.warn e.backtrace.join("\n") Rails.logger.warn e.backtrace.join("\n")

View file

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

View file

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

View file

@ -1,4 +1,9 @@
module OutfitsHelper 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) def destination_tag(value)
hidden_field_tag 'destination', value, :id => nil hidden_field_tag 'destination', value, :id => nil
end end
@ -65,27 +70,16 @@ module OutfitsHelper
text_field_tag 'name', nil, options text_field_tag 'name', nil, options
end end
def outfit_viewer(...) def outfit_viewer(outfit_or_options)
render partial: "outfit_viewer", outfit = if outfit_or_options.is_a? Hash
locals: parse_outfit_viewer_options(...) Outfit.new(outfit_or_options)
end elsif outfit_or_options.is_a? Outfit
outfit_or_options
def support_outfit_viewer(...) else
render partial: "support_outfit_viewer", raise TypeError, "must be an outfit or hash of options to create one"
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"
end end
{outfit:, preferred_image_format:, html_options:} render partial: "outfit_viewer", locals: {outfit:}
end end
end end

View file

@ -16,26 +16,7 @@ module PetStatesHelper
when "UNCONVERTED" when "UNCONVERTED"
"Unconverted" "Unconverted"
else else
"Not labeled yet" "(Unknown)"
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,
)
end end
end end
end end

View file

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

View file

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

View file

@ -777,13 +777,8 @@ function StyleExplanation() {
opacity="0.7" opacity="0.7"
marginTop="2" marginTop="2"
> >
<Box <Box as="a" href="/alt-styles" target="_blank" textDecoration="underline">
as="a" Alt Styles
href="/rainbow-pool/styles"
target="_blank"
textDecoration="underline"
>
Pet Styles
</Box>{" "} </Box>{" "}
are NC items that override the pet's appearance via the{" "} are NC items that override the pet's appearance via the{" "}
<Box <Box
@ -794,7 +789,7 @@ function StyleExplanation() {
> >
Styling Chamber Styling Chamber
</Box> </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> </Box>
); );
} }

View file

@ -4,68 +4,49 @@ class AltStyle < ApplicationRecord
belongs_to :species belongs_to :species
belongs_to :color 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 :swf_assets, through: :parent_swf_asset_relationships
has_many :contributions, as: :contributed, inverse_of: :contributed has_many :contributions, as: :contributed, inverse_of: :contributed
validates :body_id, presence: true validates :body_id, presence: true
validates :series_name, presence: true, allow_nil: true
validates :thumbnail_url, presence: true
before_validation :infer_thumbnail_url, unless: :thumbnail_url? before_create :infer_series_name
before_create :infer_thumbnail_url
scope :matching_name, ->(series_name, color_name, species_name) { scope :matching_name, ->(series_name, color_name, species_name) {
color = Color.find_by_name!(color_name) color = Color.find_by_name!(color_name)
species = Species.find_by_name!(species_name) species = Species.find_by_name!(species_name)
where(series_name:, color_id: color.id, species_id: species.id) where(series_name:, color_id: color.id, species_id: species.id)
} }
scope :by_creation_date, -> {
order("DATE(created_at) DESC")
}
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, I18n.translate('pet_types.human_name', color_human_name: color.human_name,
species_human_name: species.human_name) species_human_name: species.human_name)
end end
alias_method :name, :pet_name
# If the series_name hasn't yet been set manually by support staff, show the # 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, # 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 # `fits:<New?>-faerie-draik` intentionally will not work, and the canonical
# filter name will be `fits:alt-style-IDNUMBER`, instead. # filter name will be `fits:alt-style-IDNUMBER`, instead.
def series_name def series_name
real_series_name || AltStyle.placeholder_name self[:series_name] || "<New?>"
end
def real_series_name=(new_series_name)
self[:series_name] = new_series_name
end
def real_series_name
self[:series_name]
end end
# You can use this to check whether `series_name` is returning the actual # You can use this to check whether `series_name` is returning the actual
# value or its placeholder value. # value or its placeholder value.
def real_series_name? def has_real_series_name?
real_series_name.present? self[:series_name].present?
end end
def adjective_name def adjective_name
"#{series_name} #{color.human_name}" "#{series_name} #{color.human_name}"
end end
def full_name
"#{series_name} #{name}"
end
EMPTY_IMAGE_URL = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
def preview_image_url def preview_image_url
# Use the image URL for the first asset. Or, fall back to an empty image. swf_asset = swf_assets.first
swf_assets.first&.image_url || EMPTY_IMAGE_URL return nil if swf_asset.nil?
swf_asset.image_url
end end
# Given a list of items, return how they look on this alt style. # Given a list of items, return how they look on this alt style.
@ -73,6 +54,28 @@ class AltStyle < ApplicationRecord
Item.appearances_for(items, self, ...) Item.appearances_for(items, self, ...)
end end
def biology=(biology)
# TODO: This is very similar to what `PetState` does, but like… much much
# more compact? Idk if I'm missing something, or if I was just that much
# more clueless back when I wrote it, lol 😅
self.swf_assets = biology.values.map do |asset_data|
SwfAsset.from_biology_data(self.body_id, asset_data)
end
end
# 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 # At time of writing, most batches of Alt Styles thumbnails used a simple
# pattern for the item thumbnail URL, but that's not always the case anymore. # pattern for the item thumbnail URL, but that's not always the case anymore.
# For now, let's keep using this format as the default value when creating a # For now, let's keep using this format as the default value when creating a
@ -82,7 +85,7 @@ class AltStyle < ApplicationRecord
) )
DEFAULT_THUMBNAIL_URL = "https://images.neopets.com/items/mall_bg_circle.gif" DEFAULT_THUMBNAIL_URL = "https://images.neopets.com/items/mall_bg_circle.gif"
def infer_thumbnail_url def infer_thumbnail_url
if real_series_name? if has_real_series_name?
self.thumbnail_url = THUMBNAIL_URL_TEMPLATE.expand( self.thumbnail_url = THUMBNAIL_URL_TEMPLATE.expand(
series: series_name.gsub(/\s+/, '').downcase, series: series_name.gsub(/\s+/, '').downcase,
color: color.name.gsub(/\s+/, '').downcase, color: color.name.gsub(/\s+/, '').downcase,
@ -93,14 +96,6 @@ class AltStyle < ApplicationRecord
end end
end end
def real_thumbnail_url?
thumbnail_url != DEFAULT_THUMBNAIL_URL
end
def self.placeholder_name
"<New?>"
end
# For convenience in the console! # For convenience in the console!
def self.find_by_name(color_name, species_name) def self.find_by_name(color_name, species_name)
color = Color.find_by_name(color_name) color = Color.find_by_name(color_name)

View file

@ -161,7 +161,7 @@ class AuthUser < AuthRecord
# means we can wrap it in a `with_timeout` block!) # means we can wrap it in a `with_timeout` block!)
neopets_username = Sync do |task| neopets_username = Sync do |task|
task.with_timeout(5) do task.with_timeout(5) do
Neopets::NeoPass.load_main_neopets_username(auth.credentials.token) NeoPass.load_main_neopets_username(auth.credentials.token)
end end
rescue Async::TimeoutError rescue Async::TimeoutError
nil # If the request times out, just move on! nil # If the request times out, just move on!

View file

@ -1,11 +1,11 @@
class Color < ApplicationRecord class Color < ApplicationRecord
has_many :pet_types has_many :pet_types
has_many :alt_styles
scope :alphabetical, -> { order(:name) } scope :alphabetical, -> { order(:name) }
scope :basic, -> { where(basic: true) } scope :basic, -> { where(basic: true) }
scope :standard, -> { where(standard: true) } scope :standard, -> { where(standard: true) }
scope :nonstandard, -> { where(standard: false) } scope :nonstandard, -> { where(standard: false) }
scope :funny, -> { order(:prank) unless pranks_funny? }
validates :name, presence: true validates :name, presence: true
@ -14,23 +14,27 @@ class Color < ApplicationRecord
end end
def human_name def human_name
if name if prank? && !Color.pranks_funny?
name.split(' ').map { |word| word.capitalize }.join(' ') unfunny_human_name + ' ' + I18n.translate('colors.prank_suffix')
else else
I18n.translate('colors.default_human_name') unfunny_human_name
end end
end end
def to_param
name? ? human_name : id.to_s
end
def example_pet_type(preferred_species: nil) def example_pet_type(preferred_species: nil)
preferred_species ||= Species.first preferred_species ||= Species.first
pet_types.order([Arel.sql("species_id = ? DESC"), preferred_species.id], pet_types.order([Arel.sql("species_id = ? DESC"), preferred_species.id],
"species_id ASC").first "species_id ASC").first
end 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 def default_gender_presentation
if name.downcase.ends_with? "boy" if name.downcase.ends_with? "boy"
:masc :masc
@ -41,7 +45,8 @@ class Color < ApplicationRecord
end end
end end
def self.param_to_id(param) def self.pranks_funny?
param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id now = Time.now.in_time_zone('Pacific Time (US & Canada)')
now.month == 4 && now.day == 1
end end
end end

View file

@ -1,3 +1,6 @@
require "async"
require "async/barrier"
class Item < ApplicationRecord class Item < ApplicationRecord
include PrettyParam include PrettyParam
include Item::Dyeworks include Item::Dyeworks
@ -7,29 +10,16 @@ class Item < ApplicationRecord
SwfAssetType = 'object' SwfAssetType = 'object'
serialize :cached_compatible_body_ids, coder: Serializers::IntegerSet
serialize :cached_occupied_zone_ids, coder: Serializers::IntegerSet
has_many :closet_hangers 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_one :nc_mall_record
has_many :parent_swf_asset_relationships, as: :parent has_many :parent_swf_asset_relationships, :as => :parent
has_many :swf_assets, through: :parent_swf_asset_relationships has_many :swf_assets, :through => :parent_swf_asset_relationships
belongs_to :dyeworks_base_item, class_name: "Item", belongs_to :dyeworks_base_item, class_name: "Item",
default: -> { inferred_dyeworks_base_item }, optional: true default: -> { inferred_dyeworks_base_item }, optional: true
has_many :dyeworks_variants, class_name: "Item", has_many :dyeworks_variants, class_name: "Item",
inverse_of: :dyeworks_base_item inverse_of: :dyeworks_base_item
# We require a name field. A number of other fields must be *specified*: they
# 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 attr_writer :current_body_id, :owned, :wanted
@ -70,25 +60,39 @@ class Item < ApplicationRecord
where('description NOT LIKE ?', where('description NOT LIKE ?',
'%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%') '%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%')
} }
scope :is_modeled, -> {
where(cached_predicted_fully_modeled: true)
}
scope :is_not_modeled, -> {
where(cached_predicted_fully_modeled: false)
}
scope :occupies, ->(zone_label) { scope :occupies, ->(zone_label) {
Zone.matching_label(zone_label). zone_ids = Zone.matching_label(zone_label).map(&:id)
map { |z| occupies_zone_id(z.id) }.reduce(none, &:or)
# 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) { scope :not_occupies, ->(zone_label) {
Zone.matching_label(zone_label). zone_ids = Zone.matching_label(zone_label).map(&:id)
map { |z| not_occupies_zone_id(z.id) }.reduce(all, &:and) i = Item.arel_table
} sa = SwfAsset.arel_table
scope :occupies_zone_id, ->(zone_id) { # Querying for "has NO swf_assets matching these zone IDs" is trickier than
where("FIND_IN_SET(?, cached_occupied_zone_ids) > 0", zone_id) # 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,
scope :not_occupies_zone_id, ->(zone_id) { # and assert that it must not find a match. (This is uhh, not exactly fast,
where.not("FIND_IN_SET(?, cached_occupied_zone_ids) > 0", zone_id) # 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) { scope :restricts, ->(zone_label) {
zone_ids = Zone.matching_label(zone_label).map(&:id) zone_ids = Zone.matching_label(zone_label).map(&:id)
@ -101,12 +105,31 @@ class Item < ApplicationRecord
where("NOT (#{condition})", *zone_ids) where("NOT (#{condition})", *zone_ids)
} }
scope :fits, ->(body_id) { scope :fits, ->(body_id) {
where("FIND_IN_SET(?, cached_compatible_body_ids) > 0", body_id). joins(:swf_assets).where(swf_assets: {body_id: [body_id, 0]}).distinct
or(where("FIND_IN_SET('0', cached_compatible_body_ids) > 0"))
} }
scope :not_fits, ->(body_id) { scope :not_fits, ->(body_id) {
where.not("FIND_IN_SET(?, cached_compatible_body_ids) > 0", body_id). i = Item.arel_table
and(where.not("FIND_IN_SET('0', cached_compatible_body_ids) > 0")) 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 def nc_trade_value
@ -273,23 +296,6 @@ class Item < ApplicationRecord
restricted_zones + occupied_zones restricted_zones + occupied_zones
end 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 def species_support_ids
@species_support_ids_array ||= read_attribute('species_support_ids').split(',').map(&:to_i) rescue nil @species_support_ids_array ||= read_attribute('species_support_ids').split(',').map(&:to_i) rescue nil
end end
@ -299,83 +305,70 @@ class Item < ApplicationRecord
replacement = replacement.join(',') if replacement.is_a?(Array) replacement = replacement.join(',') if replacement.is_a?(Array)
write_attribute('species_support_ids', replacement) write_attribute('species_support_ids', replacement)
end end
def support_species?(species)
species_support_ids.blank? || species_support_ids.include?(species.id)
end
def modeling_hinted_done? def modeled_body_ids
modeling_status_hint == "done" || modeling_status_hint == "glitchy" @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 end
def predicted_body_ids def predicted_body_ids
@predicted_body_ids ||= if modeling_hinted_done? @predicted_body_ids ||= if modeled_body_ids.include?(0)
# If we've manually set this item to no longer report as needing modeling,
# predict that the current bodies are all of the compatible bodies.
compatible_body_ids
elsif compatible_body_ids.include?(0)
# Oh, look, it's already known to fit everybody! Sweet. We're done. (This # Oh, look, it's already known to fit everybody! Sweet. We're done. (This
# isn't folded into the case below, in case this item somehow got a # isn't folded into the case below, in case this item somehow got a
# body-specific and non-body-specific asset. In all the cases I've seen # body-specific and non-body-specific asset. In all the cases I've seen
# it, that indicates a glitched item, but this method chooses to reflect # 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 # behavior elsewhere in the app by saying that we can put this item on
# anybody. (Heh. Any body.)) # anybody. (Heh. Any body.))
compatible_body_ids modeled_body_ids
elsif compatible_body_ids.size == 1 elsif modeled_body_ids.size == 1
# This might just be a species-specific item. Let's be conservative in # This might just be a species-specific item. Let's be conservative in
# our prediction, though we'll revise it if we see another body ID. # our prediction, though we'll revise it if we see another body ID.
compatible_body_ids modeled_body_ids
elsif compatible_body_ids.size == 0
# If somehow we have this item, but not any modeling data for it (weird!),
# consider it to fit all standard pet types until shown otherwise.
PetType.basic.released_before(released_at_estimate).
distinct.pluck(:body_id).sort
else else
# First, find our compatible pet types, then pair each body ID with its # If an item is worn by more than one body, then it must be wearable by
# color. (As an optimization, we omit standard colors, other than the # all bodies of the same color. (To my knowledge, anyway. I'm not aware
# basic colors. We also flatten the basic colors into the single color # of any exceptions.) So, let's find those bodies by first finding those
# ID "basic", so we can treat them specially.) # colors.
compatible_pairs = compatible_pet_types.joins(:color). basic_modeled_body_ids, nonbasic_modeled_body_ids = modeled_body_ids.
merge(Color.nonstandard.or(Color.basic)). partition { |bi| basic_body_ids.include?(bi) }
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. output = []
compatible_color_ids_by_body_id = {}.tap do |h| if basic_modeled_body_ids.present?
compatible_pairs.each do |(color_id, body_id)| output += basic_body_ids
h[body_id] ||= []
h[body_id] << color_id
end
end end
if nonbasic_modeled_body_ids.present?
# Find non-basic colors with at least one unique compatible body. (This nonbasic_modeled_color_ids = PetType.select('DISTINCT color_id').
# means we'll ignore e.g. the Maraquan Mynci, which has the same body as where(body_id: nonbasic_modeled_body_ids).
# the Blue Mynci, as not indicating Maraquan compatibility in general.) map(&:color_id)
modelable_color_ids = output += PetType.select('DISTINCT body_id').
compatible_color_ids_by_body_id. where(color_id: nonbasic_modeled_color_ids).
filter { |k, v| v.size == 1 && v.first != "basic" }. map(&:body_id)
values.map(&:first).uniq end
output
# We can model on basic pets (perhaps in addition to the above) if we
# find at least one compatible basic body that doesn't *also* fit any of
# the modelable colors we identified above.
basic_is_modelable =
compatible_color_ids_by_body_id.values.
any? { |v| v.include?("basic") && (v & modelable_color_ids).empty? }
# Filter to pet types that match the colors that seem compatible.
predicted_pet_types =
(basic_is_modelable ? PetType.basic : PetType.none).
or(PetType.where(color_id: modelable_color_ids))
# Only include species that were released when this item was. If we don't
# know our creation date (we don't have it for some old records), assume
# it's pretty old.
predicted_pet_types.merge! PetType.released_before(released_at_estimate)
# Get all body IDs for the pet types we decided are modelable.
predicted_pet_types.distinct.pluck(:body_id).sort
end end
end end
def predicted_missing_body_ids 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 end
def predicted_missing_standard_body_ids_by_species_id def predicted_missing_standard_body_ids_by_species_id
@ -395,8 +388,9 @@ class Item < ApplicationRecord
end end
def predicted_missing_nonstandard_body_pet_types def predicted_missing_nonstandard_body_pet_types
body_ids = predicted_missing_body_ids - PetType.basic_body_ids PetType.joins(:color).
PetType.joins(:color).where(body_id: body_ids, colors: {standard: false}) where(body_id: predicted_missing_body_ids - basic_body_ids,
colors: {standard: false})
end end
def predicted_missing_nonstandard_body_ids_by_species_by_color def predicted_missing_nonstandard_body_ids_by_species_by_color
@ -421,19 +415,12 @@ class Item < ApplicationRecord
body_ids_by_species_by_color body_ids_by_species_by_color
end end
def predicted_fully_modeled?(use_cached: true) def predicted_fully_modeled?
return cached_predicted_fully_modeled? if use_cached
predicted_missing_body_ids.empty? predicted_missing_body_ids.empty?
end end
def predicted_modeled_ratio def predicted_modeled_ratio
compatible_body_ids.size.to_f / predicted_body_ids.size modeled_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 end
def as_json(options={}) def as_json(options={})
@ -443,9 +430,7 @@ class Item < ApplicationRecord
}.merge(options)) }.merge(options))
end end
def compatible_body_ids(use_cached: true) def compatible_body_ids
return cached_compatible_body_ids if use_cached
swf_assets.map(&:body_id).uniq swf_assets.map(&:body_id).uniq
end end
@ -668,10 +653,21 @@ class Item < ApplicationRecord
end end
def self.preload_nc_trade_values(items) def self.preload_nc_trade_values(items)
DTIRequests.load_many(max_at_once: 10) do |task| # Only allow 10 trade values to be loaded at a time.
barrier = Async::Barrier.new
semaphore = Async::Semaphore.new(10, parent: barrier)
Sync do
# Load all the trade values in concurrent async tasks. (The # Load all the trade values in concurrent async tasks. (The
# `nc_trade_value` caches the value in the Item object.) # `nc_trade_value` caches the value in the Item object.)
items.each { |item| task.async { item.nc_trade_value } } items.each do |item|
semaphore.async { item.nc_trade_value }
end
# Wait until all tasks are done.
barrier.wait
ensure
barrier.stop # If something goes wrong, clean up all tasks.
end end
items items

View file

@ -117,7 +117,7 @@ class Item
)\z )\z
}x }x
def inferred_dyeworks_base_item 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? return nil if name_match.nil?
Item.find_by_name(name_match["base"]) Item.find_by_name(name_match["base"])

View file

@ -132,8 +132,6 @@ class Item
is_positive ? Filter.is_np : Filter.is_not_np is_positive ? Filter.is_np : Filter.is_not_np
when 'pb' when 'pb'
is_positive ? Filter.is_pb : Filter.is_not_pb is_positive ? Filter.is_pb : Filter.is_not_pb
when 'modeled'
is_positive ? Filter.is_modeled : Filter.is_not_modeled
else else
raise_search_error "not_found.label", label: "is:#{value}" raise_search_error "not_found.label", label: "is:#{value}"
end end
@ -348,14 +346,6 @@ class Item
self.new Item.is_not_pb, '-is:pb' self.new Item.is_not_pb, '-is:pb'
end end
def self.is_modeled
self.new Item.is_modeled, 'is:modeled'
end
def self.is_not_modeled
self.new Item.is_not_modeled, '-is:modeled'
end
private private
# Add quotes around the value, if needed. # Add quotes around the value, if needed.
@ -377,7 +367,7 @@ class Item
# If the real series name has been set in the database by support # 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. # staff, use that for the canonical filter text for this alt style.
# Otherwise, represent this alt style by ID. # 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 series_name = alt_style.series_name.downcase
color_name = alt_style.color.name.downcase color_name = alt_style.color.name.downcase
species_name = alt_style.species.name.downcase species_name = alt_style.species.name.downcase

View file

@ -4,9 +4,6 @@ class ParentSwfAssetRelationship < ApplicationRecord
belongs_to :parent, :polymorphic => true belongs_to :parent, :polymorphic => true
belongs_to :swf_asset belongs_to :swf_asset
after_save :update_parent_cached_fields
after_destroy :update_parent_cached_fields
def item=(replacement) def item=(replacement)
self.parent = replacement self.parent = replacement
@ -19,8 +16,4 @@ class ParentSwfAssetRelationship < ApplicationRecord
def pet_state=(replacement) def pet_state=(replacement)
self.parent = replacement self.parent = replacement
end end
def update_parent_cached_fields
parent.try(:update_cached_fields)
end
end end

View file

@ -1,20 +1,82 @@
require 'rocketamf_extensions/remote_gateway'
require 'ostruct'
class Pet < ApplicationRecord 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 belongs_to :pet_type
attr_reader :items, :pet_state, :alt_style attr_reader :items, :pet_state, :alt_style
def load!(timeout: nil) scope :with_pet_type_color_ids, ->(color_ids) {
raise ModelingDisabled unless Rails.configuration.modeling_enabled joins(:pet_type).where(PetType.arel_table[:id].in(color_ids))
}
viewer_data_hash = Neopets::CustomPets.fetch_viewer_data(name, timeout:) def load!(timeout: nil)
use_modeling_snapshot(ModelingSnapshot.new(viewer_data_hash)) viewer_data = self.class.fetch_viewer_data(name, timeout:)
use_viewer_data(viewer_data)
end end
def use_modeling_snapshot(snapshot) def use_viewer_data(viewer_data)
self.pet_type = snapshot.pet_type pet_data = viewer_data[:custom_pet]
@pet_state = snapshot.pet_state
@alt_style = snapshot.alt_style raise UnexpectedDataFormat unless pet_data[:species_id]
@items = snapshot.items raise UnexpectedDataFormat unless pet_data[:color_id]
raise UnexpectedDataFormat unless pet_data[:body_id]
has_alt_style = pet_data[:alt_style].present?
self.pet_type = PetType.find_or_initialize_by(
species_id: pet_data[:species_id].to_i,
color_id: pet_data[:color_id].to_i
)
begin
new_image_hash = 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 end
def wardrobe_query def wardrobe_query
@ -25,7 +87,6 @@ class Pet < ApplicationRecord
pose: self.pet_state.pose, pose: self.pet_state.pose,
state: self.pet_state.id, state: self.pet_state.id,
objects: self.items.map(&:id), objects: self.items.map(&:id),
style: self.alt_style ? self.alt_style.id : nil,
}.to_query }.to_query
end end
@ -40,8 +101,11 @@ class Pet < ApplicationRecord
before_validation do before_validation do
pet_type.save! pet_type.save!
@pet_state.save! if @pet_state if @pet_state
@pet_state.save!
@pet_state.handle_assets!
end
if @items if @items
@items.each do |item| @items.each do |item|
item.save! if item.changed? item.save! if item.changed?
@ -60,6 +124,60 @@ class Pet < ApplicationRecord
pet pet
end 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 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 end

View file

@ -1,104 +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|
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,
)
end
end
end
def items
@items ||= Item.collection_from_pet_type_and_registries(
pet_type, @object_info_registry, @object_asset_registry
)
end
private
def biology_assets
@biology_assets ||= begin
biology = @custom_pet[:alt_style].present? ?
@custom_pet[:original_biology] :
@custom_pet[:biology_by_zone]
assets_from_biology(biology)
end
end
def item_assets_for(item_id)
all_infos = @object_asset_registry.values
infos = all_infos.select { |a| a[:obj_info_id].to_i == item_id.to_i }
infos.map do |asset_data|
remote_id = asset_data[:asset_id].to_i
SwfAsset.find_or_initialize_by(type: "object", remote_id:).tap do |swf_asset|
swf_asset.origin_pet_type = pet_type
swf_asset.origin_object_data = asset_data
end
end
end
def alt_style_assets
raise Pet::UnexpectedDataFormat if @custom_pet[:biology_by_zone].empty?
assets_from_biology(@custom_pet[:biology_by_zone])
end
def assets_from_biology(biology)
raise Pet::UnexpectedDataFormat if biology.empty?
body_id = @custom_pet[:body_id].to_i
biology.values.map { |b| SwfAsset.from_biology_data(body_id, b) }
end
end

View file

@ -1,30 +1,20 @@
class PetState < ApplicationRecord class PetState < ApplicationRecord
SwfAssetType = 'biology' SwfAssetType = 'biology'
MAIN_POSES = %w(HAPPY_FEM HAPPY_MASC SAD_FEM SAD_MASC SICK_FEM SICK_MASC)
has_many :contributions, :as => :contributed, has_many :contributions, :as => :contributed,
:inverse_of => :contributed # in case of duplicates being merged :inverse_of => :contributed # in case of duplicates being merged
has_many :outfits has_many :outfits
has_many :parent_swf_asset_relationships, :as => :parent has_many :parent_swf_asset_relationships, :as => :parent,
:autosave => false
has_many :swf_assets, :through => :parent_swf_asset_relationships has_many :swf_assets, :through => :parent_swf_asset_relationships
serialize :swf_asset_ids, coder: Serializers::IntegerSet, type: Array
belongs_to :pet_type belongs_to :pet_type
delegate :species_id, :species, :color_id, :color, to: :pet_type delegate :color, to: :pet_type
alias_method :swf_asset_ids_from_association, :swf_asset_ids alias_method :swf_asset_ids_from_association, :swf_asset_ids
scope :glitched, -> { where(glitched: true) } attr_writer :parent_swf_asset_relationships_to_update
scope :needs_labeling, -> { unlabeled.where(glitched: false) }
scope :unlabeled, -> { with_pose("UNKNOWN") }
scope :usable, -> { where(labeled: true, glitched: false) }
scope :newest, -> { order(created_at: :desc) }
scope :newest_pet_type, -> { joins(:pet_type).merge(PetType.newest) }
scope :created_before, ->(time) { where(arel_table[:created_at].lt(time)) }
# A simple ordering that tries to bring reliable pet states to the front. # A simple ordering that tries to bring reliable pet states to the front.
scope :emotion_order, -> { scope :emotion_order, -> {
@ -81,25 +71,50 @@ class PetState < ApplicationRecord
end end
end end
# TODO: More and more, wanting to refactor poses… def reassign_children_to!(main_pet_state)
def pose=(pose) self.contributions.each do |contribution|
case pose contribution.contributed = main_pet_state
when "UNKNOWN" contribution.save
label_pose nil, nil, unconverted: nil, labeled: false end
when "HAPPY_MASC" self.outfits.each do |outfit|
label_pose 1, false outfit.pet_state = main_pet_state
when "HAPPY_FEM" outfit.save
label_pose 1, true end
when "SAD_MASC" ParentSwfAssetRelationship.where(ParentSwfAssetRelationship.arel_table[:parent_id].eq(self.id)).delete_all
label_pose 2, false end
when "SAD_FEM"
label_pose 2, true def reassign_duplicates!
when "SICK_MASC" raise "This may only be applied to pet states that represent many duplicate entries" unless duplicate_ids
label_pose 4, false pet_states = duplicate_ids.split(',').map do |id|
when "SICK_FEM" PetState.find(id.to_i)
label_pose 4, true end
when "UNCONVERTED" main_pet_state = pet_states.shift
label_pose nil, nil, unconverted: true 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
end end
@ -107,75 +122,58 @@ class PetState < ApplicationRecord
"#{id}-#{pose.split('_').map(&:capitalize).join('-')}" "#{id}-#{pose.split('_').map(&:capitalize).join('-')}"
end end
# Because our column is named `swf_asset_ids`, we need to ensure writes to def self.from_pet_type_and_biology_info(pet_type, info)
# it go to the attribute, and not the thing ActiveRecord does of finding the swf_asset_ids = []
# relevant `swf_assets`. info.each do |zone_id, asset_info|
# TODO: Consider renaming the column to `cached_swf_asset_ids`? if zone_id.present? && asset_info
def swf_asset_ids=(new_swf_asset_ids) swf_asset_ids << asset_info[:part_id].to_i
write_attribute(:swf_asset_ids, new_swf_asset_ids)
end
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!)
end end
end end
end swf_asset_ids_str = swf_asset_ids.sort.join(',')
if pet_type.new_record?
def self.next_unlabeled_appearance(after_id: nil) pet_state = self.new :swf_asset_ids => swf_asset_ids_str
# Rather than just getting the newest unlabeled pet state, prioritize the else
# newest *pet type*. This better matches the user's perception of what the pet_state = self.find_or_initialize_by(
# newest state is, because the Rainbow Pool UI is grouped by pet type! pet_type_id: pet_type.id,
pet_states = needs_labeling.newest_pet_type.newest swf_asset_ids: swf_asset_ids_str
# If `after_id` is given, convert it from a PetState ID to creation
# timestamps, and find the next record prior to those timestamps. This
# enables skipping past records the user doesn't want to label.
if after_id
begin
after_pet_state = PetState.find(after_id)
before_pt_created_at = after_pet_state.pet_type.created_at
before_ps_created_at = after_pet_state.created_at
rescue ActiveRecord::RecordNotFound
Rails.logger.warn "PetState.next_unlabeled_appearance: Could not " +
"find pet state ##{after_id}"
return nil
end
# Because we sort by `newest_pet_type` first, then breaks ties by
# `newest`, our filter needs to operate the same way. Kudos to:
# https://brunoscheufler.com/blog/2022-01-01-paginating-large-ordered-datasets-with-cursor-based-pagination
pet_states.merge!(
PetType.created_before(before_pt_created_at).or(
PetType.created_at(before_pt_created_at).and(
PetState.created_before(before_ps_created_at)
)
)
) )
end end
existing_swf_assets = SwfAsset.biology_assets.includes(:zone).
pet_states.first 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
end end

View file

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

View file

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

View file

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

View file

@ -1,6 +1,11 @@
require "addressable/template" 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
module Neopets::NCMall
# Load the NC Mall home page content area, and return its useful data. # 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" HOME_PAGE_URL = "https://ncmall.neopets.com/mall/ajax/home_page.phtml"
def self.load_home_page def self.load_home_page
@ -21,10 +26,12 @@ module Neopets::NCMall
PAGE_LINK_PATTERN = /load_items_pane\(['"](.+?)['"], ([0-9]+)\).+?>(.+?)</ PAGE_LINK_PATTERN = /load_items_pane\(['"](.+?)['"], ([0-9]+)\).+?>(.+?)</
def self.load_page_links def self.load_page_links
html = Sync do html = Sync do
DTIRequests.get(ROOT_DOCUMENT_URL) do |response| INTERNET.get(ROOT_DOCUMENT_URL, [
["User-Agent", Rails.configuration.user_agent_for_neopets],
]) do |response|
if response.status != 200 if response.status != 200
raise ResponseNotOK.new(response.status), raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{ROOT_DOCUMENT_URL})" "expected status 200 but got #{response.status} (#{url})"
end end
response.read response.read
@ -38,41 +45,13 @@ module Neopets::NCMall
uniq uniq
end end
STYLING_STUDIO_URL = "https://www.neopets.com/np-templates/ajax/stylingstudio/studio.php"
def self.load_styles(species_id:, neologin:)
Sync do
DTIRequests.post(
STYLING_STUDIO_URL,
[
["Content-Type", "application/x-www-form-urlencoded"],
["Cookie", "neologin=#{neologin}"],
["X-Requested-With", "XMLHttpRequest"],
],
{tab: 1, mode: "getStyles", 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
rescue JSON::ParserError, KeyError
raise UnexpectedResponseFormat
end
end
end
end
private private
def self.load_page_by_url(url) def self.load_page_by_url(url)
Sync do Sync do
DTIRequests.get(url) do |response| INTERNET.get(url, [
["User-Agent", Rails.configuration.user_agent_for_neopets],
]) do |response|
if response.status != 200 if response.status != 200
raise ResponseNotOK.new(response.status), raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{url})" "expected status 200 but got #{response.status} (#{url})"

View file

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

View file

@ -1,67 +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, 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
class PetNotFound < RuntimeError;end
class DownloadError < RuntimeError;end
end

View file

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

View file

@ -1,12 +1,4 @@
%li %li.alt-style
= link_to view_or_edit_alt_style_url(alt_style) do = link_to alt_style.preview_image_url do
= image_tag alt_style.preview_image_url, class: "preview", loading: "lazy" = image_tag alt_style.thumbnail_url, class: 'alt-style-thumbnail'
.name .alt-style-name= alt_style.name
%span= alt_style.series_name
%span= alt_style.pet_name
.info
%p
Added
= time_tag alt_style.created_at,
title: alt_style.created_at.to_formatted_s(:long_nst) do
= time_with_only_month_if_old alt_style.created_at

View file

@ -1,37 +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, class: "support-form" 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 :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"

View file

@ -1,46 +1,18 @@
- title "NC Pet Styles" - title "Styling Studio"
- use_responsive_design
%ul.breadcrumbs %p
%li= link_to "Rainbow Pool", pet_types_path Here's all the new NC Pet Styles we have! They're available in the app too,
%li Pet Styles by opening the emotion picker and clicking the "Styles" tab.
:markdown %p
Pet Styles drastically change the appearance of your pet! They're [available If you have an Alt Style we don't, please model it by entering your pet's
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
name on the homepage! Thank you! 💖 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!
= form_with url: alt_styles_path, method: :get, - @alt_styles.group_by(&:species).each do |species, species_styles|
class: "rainbow-pool-filters" do |f| %h2.alt-styles-header= species.human_name
%fieldset %ul.alt-styles-list= render partial: "alt_style", collection: species_styles
%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
= will_paginate @alt_styles, class: "rainbow-pool-pagination"
%ul.rainbow-pool-list= render @alt_styles
= will_paginate @alt_styles, class: "rainbow-pool-pagination"
- content_for :stylesheets do
= stylesheet_link_tag "application/breadcrumbs"
= stylesheet_link_tag "application/rainbow-pool"
= page_stylesheet_link_tag "alt_styles/index"

View file

@ -1,5 +1,4 @@
- html_options = {} unless defined? html_options %outfit-viewer
= content_tag "outfit-viewer", **html_options do
.loading-indicator= render partial: "hanger_spinner" .loading-indicator= render partial: "hanger_spinner"
%label.play-pause-button{title: "Pause/play animations"} %label.play-pause-button{title: "Pause/play animations"}
@ -21,9 +20,7 @@
} }
- if swf_asset.canvas_movie? - if swf_asset.canvas_movie?
%iframe{src: swf_asset_path(swf_asset, playing: outfit_viewer_is_playing ? true : nil)} %iframe{src: swf_asset_path(swf_asset, playing: outfit_viewer_is_playing ? true : nil)}
- elsif preferred_image_format == :svg && swf_asset.svg_url? - elsif swf_asset.image_url.present?
= image_tag swf_asset.svg_url, alt: "", loading: "lazy"
- elsif swf_asset.image_url?
= image_tag swf_asset.image_url, alt: "", loading: "lazy" = image_tag swf_asset.image_url, alt: "", loading: "lazy"
- else - else
/ No movie or image available for SWF asset: #{swf_asset.url} / No movie or image available for SWF asset: #{swf_asset.url}

View file

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

View file

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

View file

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

View file

@ -46,8 +46,6 @@
= link_to t('items.show.closet_hangers.button'), = link_to t('items.show.closet_hangers.button'),
user_closet_hangers_path(current_user), user_closet_hangers_path(current_user),
class: 'user-lists-form-opener' class: 'user-lists-form-opener'
- if support_staff?
= link_to "Edit", edit_item_path(item)
- if user_signed_in? - if user_signed_in?
= form_tag update_quantities_user_item_closet_hangers_path(user_id: current_user, item_id: item), method: :put, class: 'user-lists-form', hidden: item_header_user_lists_form_state != "open" do = form_tag update_quantities_user_item_closet_hangers_path(user_id: current_user, item_id: item), method: :put, class: 'user-lists-form', hidden: item_header_user_lists_form_state != "open" do

View file

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

View file

@ -34,7 +34,7 @@
%span.error-icon{title: "We haven't seen this item on this pet before."} ⚠️ %span.error-icon{title: "We haven't seen this item on this pet before."} ⚠️
= select_tag "preview[color_id]", = select_tag "preview[color_id]",
options_from_collection_for_select(Color.alphabetical, options_from_collection_for_select(Color.funny.alphabetical,
"id", "human_name", @selected_preview_pet_type.color_id) "id", "human_name", @selected_preview_pet_type.color_id)
= select_tag "preview[species_id]", = select_tag "preview[species_id]",
options_from_collection_for_select(Species.alphabetical, options_from_collection_for_select(Species.alphabetical,
@ -70,10 +70,9 @@
%li< %li<
= zone.label = zone.label
- if item_zone_partial_fit? appearances_in_zone, @all_appearances - if item_zone_partial_fit? appearances_in_zone, @all_appearances
= " "
%span.zone-species-info{ %span.zone-species-info{
title: item_zone_species_list(appearances_in_zone) title: item_zone_species_list(appearances_in_zone)
}< }
(#{appearances_in_zone.size} species) (#{appearances_in_zone.size} species)
- else - else
%span.no-zones (None) %span.no-zones (None)

View file

@ -29,26 +29,6 @@
= render 'analytics' = render 'analytics'
%body{:class => body_class} %body{:class => body_class}
#container #container
%nav#main-nav
- if home_link?
%a#home-link{:href => root_path}
%span= t 'app_name'
#userbar
- if user_signed_in?
%span
= t '.userbar.greeting', :user_name => current_user.name
= userbar_contributions_summary(current_user)
= link_to t('.userbar.items'), user_closet_hangers_path(current_user), :id => 'userbar-items-link'
= link_to t('.userbar.outfits'), current_user_outfits_path
= link_to t('.userbar.settings'), edit_auth_user_path
= button_to t('.userbar.logout'), destroy_auth_user_session_path, method: :delete,
params: {return_to: request.fullpath}
- else
= link_to auth_user_sign_in_path_with_return_to,
id: 'userbar-log-in', "data-turbo-prefetch": false do
%span= t('.userbar.login')
= yield :before_title = yield :before_title
= render 'announcement' = render 'announcement'
- if content_for?(:title) && show_title_header? - if content_for?(:title) && show_title_header?
@ -61,6 +41,25 @@
- else - else
= yield = yield
- if home_link?
%a#home-link{:href => root_path}
%span= t 'app_name'
#userbar
- if user_signed_in?
%span
= t '.userbar.greeting', :user_name => current_user.name
= userbar_contributions_summary(current_user)
= link_to t('.userbar.items'), user_closet_hangers_path(current_user), :id => 'userbar-items-link'
= link_to t('.userbar.outfits'), current_user_outfits_path
= link_to t('.userbar.settings'), edit_auth_user_path
= button_to t('.userbar.logout'), destroy_auth_user_session_path, method: :delete,
params: {return_to: request.fullpath}
- else
= link_to auth_user_sign_in_path_with_return_to,
id: 'userbar-log-in', "data-turbo-prefetch": false do
%span= t('.userbar.login')
#footer #footer
= form_tag choose_locale_path, :id => 'locale-form' do = form_tag choose_locale_path, :id => 'locale-form' do
= hidden_field_tag 'return_to', request.fullpath = hidden_field_tag 'return_to', request.fullpath
@ -73,7 +72,6 @@
= link_to t('.footer.terms', date: terms_updated_timestamp), = link_to t('.footer.terms', date: terms_updated_timestamp),
terms_path terms_path
%li= link_to t('.footer.blog'), "https://blog.openneo.net/" %li= link_to t('.footer.blog'), "https://blog.openneo.net/"
%li= link_to t('modeling_hub'), bulk_pets_path
%div %div
#{t('.footer.contact')}: #{t('.footer.contact')}:

View file

@ -4,19 +4,22 @@
%p#pet-not-found.alert= t 'pets.load.not_found' %p#pet-not-found.alert= t 'pets.load.not_found'
- hide_after Date.new(2024, 12, 8) do - if show_announcement?
%section.announcement %section.announcement
= image_tag "about/announcement.png", width: 70, height: 70, = image_tag "about/announcement-broom.png", width: 70, height: 70,
srcset: {"about/announcement@2x.png": "2x"} srcset: {"about/announcement-broom@2x.png": "2x"},
class: "neopass-thumbnail"
.content .content
%p %p
%strong Oh wow, it's busy this time of year! %strong
We've temporarily moved to a bigger server, to help us handle the extra = link_to "State of DTI: 2024!",
load. Hopefully this keeps us running smooth! "https://blog.openneo.net/2024/09/20/state-of-dti-2024.html"
Here's what we've been up to this year! We talk a bit about the
cleanups, the partnerships, and the future!
%p %p
Happy holidays, everyone! Here's hoping you, and your families, and your The themes are stability, simplicity, and sustainability. We've been
precious pets—both online and off—stay happy and healthy for the year online for 15 years now, and we're gonna keep doing our best to keep
to come 💜 DTI here for a long time to come!
#outfit-forms #outfit-forms
#pet-preview #pet-preview
@ -65,18 +68,16 @@
= submit_tag t('.infinite_closet.item_search.submit') = submit_tag t('.infinite_closet.item_search.submit')
%li %li
%h3= link_to t('rainbow_pool'), pet_types_path %h3= link_to t('modeling_hub'), bulk_pets_path
= link_to pet_types_path do = link_to bulk_pets_path do
= image_tag 'rainbow_pool.png' = image_tag 'https://images.neopets.com/items/mall_ac_garland_spotlight.gif'
.section-info .section-info
%strong= t('.rainbow_pool.tagline') %strong= t '.modeling_hub.tagline'
%p= t('.rainbow_pool.description') %p= t '.modeling_hub.description'
= form_with url: pet_types_path, method: 'GET' do |form| = form_tag load_pet_path, method: 'POST' do
= form.select :color, @colors.map(&:human_name), = pet_name_tag placeholder: t('.modeling_hub.load_pet.placeholder'),
include_blank: t('.rainbow_pool.filters.color') required: true
= form.select :species, @species.map(&:human_name), = submit_tag t('.modeling_hub.load_pet.submit')
include_blank: t('.rainbow_pool.filters.species')
= form.submit t('.rainbow_pool.filters.submit'), name: nil
- if @latest_contribution # will be nil for a fresh copy of the site ;P - if @latest_contribution # will be nil for a fresh copy of the site ;P
#latest-contribution #latest-contribution
@ -89,14 +90,15 @@
%h3= t '.newest_items.unmodeled.header' %h3= t '.newest_items.unmodeled.header'
%ul#newest-unmodeled-items %ul#newest-unmodeled-items
- @newest_unmodeled_items.each do |item| - @newest_unmodeled_items.each do |item|
%li{'data-item-id' => item.id} - cache "items/#{item.id} modeling_progress locale=#{I18n.locale} updated_at=#{item.updated_at.to_i}" do
= link_to image_tag(item.thumbnail_url), item, :class => 'image-link' %li{'data-item-id' => item.id}
= link_to item, :class => 'header' do = link_to image_tag(item.thumbnail_url), item, :class => 'image-link'
%h2= item.name = link_to item, :class => 'header' do
%span.meter{style: "width: #{@newest_unmodeled_items_predicted_modeled_ratio[item]*100}%"} %h2= item.name
.missing-bodies %span.meter{style: "width: #{@newest_unmodeled_items_predicted_modeled_ratio[item]*100}%"}
= render_predicted_missing_species_by_color(@newest_unmodeled_items_predicted_missing_species_by_color[item]) .missing-bodies
.models = render_predicted_missing_species_by_color(@newest_unmodeled_items_predicted_missing_species_by_color[item])
.models
- if @newest_modeled_items.present? - if @newest_modeled_items.present?
%h3= t '.newest_items.modeled.header' %h3= t '.newest_items.modeled.header'
%ul#newest-modeled-items %ul#newest-modeled-items

View file

@ -1,6 +1,6 @@
%li %li
= link_to useful_pet_state_path(pet_state.pet_type, pet_state) do = link_to [pet_state.pet_type, pet_state] do
= outfit_viewer pet_state:, class: "preview" = outfit_viewer pet_state:
.name= pose_name pet_state.pose .name= pose_name pet_state.pose
- if pet_state.glitched? - if pet_state.glitched?
%span.glitched{title: "Glitched"} 👾 %span.glitched{title: "Glitched"} 👾

View file

@ -1,45 +0,0 @@
= content_tag "support-outfit-viewer", **html_options do
= turbo_frame_tag "support-outfit-viewer-preview" do
%div
-# Render an outfit viewer in a magnifier. Use SVG by default for clarity,
-# but also offer an option to switch to PNG if it looks wrong.
%magic-magnifier
= outfit_viewer outfit,
preferred_image_format: params[:preferred_image_format] == "png" ? :png : :svg
= form_with method: :get, class: "outfit-viewer-controls" do |f|
%fieldset
%legend Format
%label
= f.radio_button "preferred_image_format", "svg",
checked: params[:preferred_image_format] != "png"
SVG
%label
= f.radio_button "preferred_image_format", "png",
checked: params[:preferred_image_format] == "png"
PNG
= f.submit "Update"
%table
%thead
%tr
%th{scope: "col"} DTI ID
%th{scope: "col"} Zone
%th{scope: "col"} Links
%tbody
- outfit.visible_layers.each do |swf_asset|
%tr
%th{scope: "row", "data-field": "id"}
= swf_asset.id
%td
= swf_asset.zone.label
(##{swf_asset.zone.id})
%td{"data-field": "links"}
%ul
- if swf_asset.image_url?
%li= link_to "PNG", swf_asset.image_url, target: "_blank"
- if swf_asset.svg_url?
%li= link_to "SVG", swf_asset.svg_url, target: "_blank"
%li= link_to "SWF", swf_asset.url, target: "_blank"
- if swf_asset.manifest_url?
%li= link_to "Manifest", swf_asset.manifest_url, target: "_blank"

View file

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

View file

@ -0,0 +1,36 @@
- title "#{@pet_type.human_name}: #{pose_name @pet_state.pose} [\##{@pet_state.id}]"
- use_responsive_design
= outfit_viewer pet_state: @pet_state
%dl
%dt{title: "Pose usually affects just the eyes and mouth. Neopets " +
"genders these as Male/Female, but I don't like those " +
"terms for like… it's just eyelashes! Sheesh!"}
Pose
%dd
= pose_name @pet_state.pose
- if @pet_state.pose == "UNCONVERTED"
(Retired, replaced by #{link_to "Alt Styles", alt_styles_path})
%dt{title: "This is our own internal ID number, nothing to do with " +
"Neopets's official data."}
DTI ID
%dd= @pet_state.id
%dt{title: "When we notice a form looks wrong, we mark it Glitched, to " +
"tell our systems to prefer other forms for this pose instead."}
Glitched?
%dd
- if @pet_state.glitched?
👾 Yes, it's bad news bonko'd
- else
✅ Not marked as Glitched
- content_for :stylesheets do
= stylesheet_link_tag "application/hanger-spinner"
= stylesheet_link_tag "application/outfit-viewer"
= stylesheet_link_tag "pet_states/show"
- content_for :javascripts do
= javascript_include_tag "outfit-viewer", async: true

View file

@ -1,21 +1,4 @@
%li %li
= link_to pet_type do = link_to pet_type do
= pet_type_image pet_type, :happy, :thumb, class: "preview" = pet_type_image pet_type, :happy, :thumb
.name= pet_type.human_name .name= pet_type.human_name
.info
- if support_staff?
%p
- if pet_type.num_unlabeled_states > 0
%span{title: "Unlabeled states"}
❓️ #{pet_type.num_unlabeled_states} +
%span{title: "Labeled main poses"}
- if pet_type.fully_labeled?
✅ #{pet_type.num_poses}/#{pet_type.num_poses}
- else
= moon_progress pet_type.num_poses, pet_type.num_poses + pet_type.num_missing_poses
#{pet_type.num_poses}/#{pet_type.num_poses + pet_type.num_missing_poses}
%p
Added
= time_tag pet_type.created_at,
title: pet_type.created_at.to_formatted_s(:long_nst) do
= time_with_only_month_if_old pet_type.created_at

View file

@ -1,40 +1,18 @@
- title "Rainbow Pool" - title "Rainbow Pool"
- use_responsive_design - use_responsive_design
:markdown = form_with method: :get, class: "pet-filters" do |form|
Welcome, welcome! These are all the colors and species of pet we've seen %fieldset
before. We have [NC Pet Styles][1], too! %legend Filter by:
= form.select :color, @color_names, selected: @selected_color&.human_name, include_blank: "Color…"
= form.select :species, @species_names, selected: @selected_species&.human_name, include_blank: "Species…"
= form.submit "Go"
If you've seen a new kind of pet, you can enter its name on the homepage to = will_paginate @pet_types
show us! Thank you so much 💖
[1]: #{alt_styles_path} %ui.pet-types= render @pet_types
- if support_staff? = will_paginate @pet_types
%p
%strong 💡 Support summary:
✅ #{number_with_delimiter @counts[:usable]} usable
+ 👾 #{number_with_delimiter @counts[:glitched]} glitched
- if @unlabeled_appearance
+ ❓️
= link_to "#{number_with_delimiter @counts[:needs_labeling]} unknown",
edit_pet_type_pet_state_path(@unlabeled_appearance.pet_type,
@unlabeled_appearance, next: "unlabeled-appearance")
\= #{number_with_delimiter @counts[:total]} total
= form_with method: :get, class: "rainbow-pool-filters" do |form|
%fieldset
%legend Filter by:
= form.select :color, @color_names, selected: @selected_color&.human_name, include_blank: "Color…"
= form.select :species, @species_names, selected: @selected_species&.human_name, include_blank: "Species…"
= form.submit "Go", name: nil
- if @pet_types.present?
= will_paginate @pet_types, class: "rainbow-pool-pagination"
%ui.rainbow-pool-list= render @pet_types
= will_paginate @pet_types, class: "rainbow-pool-pagination"
- else
%p.rainbow-pool-no-results No matching pets found!
- content_for :stylesheets do - content_for :stylesheets do
= stylesheet_link_tag "application/rainbow-pool" = stylesheet_link_tag "pet_types/index"

View file

@ -1,56 +1,19 @@
- title "#{@pet_type.human_name}" - title "#{@pet_type.human_name}"
- use_responsive_design - use_responsive_design
%ol.breadcrumbs %ul.pet-states
%li
= link_to "Rainbow Pool", pet_types_path
%li
= link_to @pet_type.possibly_new_color.human_name,
pet_types_path(color: @pet_type.possibly_new_color.human_name)
%li{"data-relation-to-prev": "sibling"}
= link_to @pet_type.possibly_new_species.human_name,
pet_types_path(species: @pet_type.possibly_new_species.human_name)
%li
Appearances
%p
These are the various appearances we've seen for this pet! We've hand-labeled
them by their emotion and their gender expression, as best we can.
%p
If you've seen another kind of #{@pet_type.human_name}, you can enter its
name on the homepage to show us! Thank you 💖
- if @pet_states[:canonical].any?(&:glitched?)
%p
Some of these appearances are marked as "glitched", but it's still the
best sample we have. If someone models an unglitched alternative for us,
we'll use that instead!
%ul.rainbow-pool-list
= render @pet_states[:canonical] = render @pet_states[:canonical]
- if @pet_states[:other].present? - if @pet_states[:other].present?
%h3 Other appearances %details
%summary Other
%p %ul.pet-states
These are some other appearances we've seen over time! = render @pet_states[:other]
- if @pet_states[:other].any?(&:labeled?)
The labeled appearances here don't appear in the outfit editor by
default anymore, because they've been replaced by better alternatives.
- unless @pet_states[:other].all?(&:labeled?)
The unlabeled appearances here <em>might</em> be what we show in the
outfit editor later, once we have the chance to label them.
%ul.rainbow-pool-list
= render @pet_states[:other]
- content_for :stylesheets do - content_for :stylesheets do
= stylesheet_link_tag "application/breadcrumbs"
= stylesheet_link_tag "application/hanger-spinner" = stylesheet_link_tag "application/hanger-spinner"
= stylesheet_link_tag "application/outfit-viewer" = stylesheet_link_tag "application/outfit-viewer"
= stylesheet_link_tag "application/rainbow-pool" = stylesheet_link_tag "pet_types/show"
= page_stylesheet_link_tag "pet_types/show"
- content_for :javascripts do - content_for :javascripts do
= javascript_include_tag "outfit-viewer", async: true = javascript_include_tag "outfit-viewer", async: true

View file

@ -72,12 +72,6 @@ module OpenneoImpressItems
# version number, etc. So let's only send this to Neopets systems, where it # version number, etc. So let's only send this to Neopets systems, where it
# should hopefully be clear who we are from context! # should hopefully be clear who we are from context!
config.user_agent_for_neopets = "Dress to Impress" config.user_agent_for_neopets = "Dress to Impress"
# Use the usual Neopets.com, unless we have an override. (At times, we've
# used this in collaboration with TNT to address the server directly,
# instead of through the CDN.)
config.neopets_origin =
ENV.fetch('NEOPETS_URL_ORIGIN', 'https://www.neopets.com')
end end
end end

View file

@ -103,10 +103,6 @@ Rails.application.configure do
# Allow connections on Vagrant's private network. # Allow connections on Vagrant's private network.
config.web_console.permissions = '10.0.2.2' config.web_console.permissions = '10.0.2.2'
# Allow pets to model new data. (If modeling is ever broken, disable this in
# production while we fix it!)
config.modeling_enabled = true
# Use a local copy of Impress 2020, presumably running on port 4000. (Can # Use a local copy of Impress 2020, presumably running on port 4000. (Can
# override this with the IMPRESS_2020_ORIGIN environment variable!) # override this with the IMPRESS_2020_ORIGIN environment variable!)
config.impress_2020_origin = ENV.fetch("IMPRESS_2020_ORIGIN", config.impress_2020_origin = ENV.fetch("IMPRESS_2020_ORIGIN",

View file

@ -122,10 +122,6 @@ Rails.application.configure do
# Skip DNS rebinding protection for the default health check endpoint. # Skip DNS rebinding protection for the default health check endpoint.
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } } # config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
# Allow pets to model new data. (If modeling is ever broken, disable this
# here while we fix it!)
config.modeling_enabled = true
# Use the live copy of Impress 2020. (Can override this with the # Use the live copy of Impress 2020. (Can override this with the
# IMPRESS_2020_ORIGIN environment variable!) # IMPRESS_2020_ORIGIN environment variable!)
config.impress_2020_origin = ENV.fetch("IMPRESS_2020_ORIGIN", config.impress_2020_origin = ENV.fetch("IMPRESS_2020_ORIGIN",

View file

@ -62,10 +62,6 @@ Rails.application.configure do
# Raise error when a before_action's only/except options reference missing actions # Raise error when a before_action's only/except options reference missing actions
config.action_controller.raise_on_missing_callback_actions = true config.action_controller.raise_on_missing_callback_actions = true
# Allow pets to model new data. (If modeling is ever broken, disable this in
# production while we fix it!)
config.modeling_enabled = true
# Use a local copy of Impress 2020, presumably running on port 4000. (Can # Use a local copy of Impress 2020, presumably running on port 4000. (Can
# override this with the IMPRESS_2020_ORIGIN environment variable!) # override this with the IMPRESS_2020_ORIGIN environment variable!)
config.impress_2020_origin = ENV.fetch("IMPRESS_2020_ORIGIN", config.impress_2020_origin = ENV.fetch("IMPRESS_2020_ORIGIN",

View file

@ -1,5 +1 @@
Date::DATE_FORMATS[:month_and_day] = "%B %e" Date::DATE_FORMATS[:month_and_day] = "%B %e"
Time::DATE_FORMATS[:long_nst] = lambda { |time|
time.in_time_zone("Pacific Time (US & Canada)").
to_formatted_s(:long) + " NST"
}

View file

@ -16,15 +16,13 @@
# end # end
ActiveSupport::Inflector.inflections(:en) do |inflect| ActiveSupport::Inflector.inflections(:en) do |inflect|
# `lib/rocketamf` => `RocketAMF` # Teach Zeitwerk that `RocketAMF` is what to expect in `lib/rocketamf`.
inflect.acronym "RocketAMF" inflect.acronym "RocketAMF"
# `neopass.rb` => `NeoPass` # Teach Zeitwerk that `NeoPass` is what to expect in `app/services/neopass.rb`.
inflect.acronym "NeoPass" inflect.acronym "NeoPass"
# `nc_mall.rb` => `NCMall` # Teach Zeitwerk that "NCMall" is what to expect in `app/services/nc_mall.rb`.
# (We do this by teaching it the word "NC".)
inflect.acronym "NC" inflect.acronym "NC"
# `dti_requests.rb` => `DTIRequests`
inflect.acronym "DTI"
end end

View file

@ -35,8 +35,7 @@ en-MEEP:
terms: Terms of Use (meeped Sep 2022) terms: Terms of Use (meeped Sep 2022)
contact: Meeptact contact: Meeptact
email: Questions, comments, meepits email: Questions, comments, meepits
copyright: copyright: Images © 1999%{year} World of Neopets, Inc. All Rights Reserved.
Images © 1999%{year} World of Neopets, Inc. All Rights Reserved.
Used With Permission. Meep. Used With Permission. Meep.
items: items:
@ -554,6 +553,14 @@ en-MEEP:
item_search: item_search:
placeholder: meep an item… placeholder: meep an item…
submit: meep submit: meep
modeling_hub:
tagline: Found somemeep?
description:
Meep a pet's meep here and we'll meep a meep of what it's wearing.
Thanks so meep!
load_pet:
placeholder: meep a pet…
submit: meep
latest_contribution: latest_contribution:
header: Contribumeeps header: Contribumeeps
description_html: "%{user_link} meeped us %{contributed_description}. description_html: "%{user_link} meeped us %{contributed_description}.
@ -627,6 +634,10 @@ en-MEEP:
load: load:
not_found: We couldn't meep a pet by that name. Is it meeped correctly? not_found: We couldn't meep a pet by that name. Is it meeped correctly?
asset_download_error:
We meeped the pet and what it's wearing, but couldn't meep the
associated meepia files. Maybe Neopets is down, or changed their
firewall rules? Please meep again later!
pet_download_error: pet_download_error:
We couldn't meep to Neopets to meep up the pet. Maybe they're down. We couldn't meep to Neopets to meep up the pet. Maybe they're down.
Please try meep later! Please try meep later!

View file

@ -4,7 +4,6 @@ en:
your_items: Your Items your_items: Your Items
infinite_closet: Infinite Closet infinite_closet: Infinite Closet
modeling_hub: Modeling Hub modeling_hub: Modeling Hub
rainbow_pool: Rainbow Pool
locale_name: English locale_name: English
activerecord: activerecord:
@ -33,12 +32,11 @@ en:
footer: footer:
source_code: Source Code source_code: Source Code
terms: Terms of Use (%{date}) terms: Terms of Use (updated %{date})
blog: Blog blog: Blog
contact: Contact contact: Contact
email: Questions, comments, bugs email: Questions, comments, bugs
copyright: copyright: Images © 1999%{year} World of Neopets, Inc. All Rights Reserved.
Images © 1999%{year} World of Neopets, Inc. All Rights Reserved.
Used With Permission Used With Permission
items: items:
@ -169,7 +167,8 @@ en:
submit: Save submit: Save
edit: Edit edit: Edit
delete: Delete delete: Delete
delete_confirmation: Are you sure you want to delete "%{list_name}"? delete_confirmation:
Are you sure you want to delete "%{list_name}"?
If you do, we'll delete all the items in it, too. If you do, we'll delete all the items in it, too.
remove_all: remove_all:
confirm: "Remove all items from this list?" confirm: "Remove all items from this list?"
@ -216,6 +215,7 @@ en:
colors: colors:
default_human_name: (a new color) default_human_name: (a new color)
prank_suffix: (fake)
contributions: contributions:
contributed_description: contributed_description:
@ -229,7 +229,7 @@ en:
swf_asset_html: "%{item_description} on a new body type" swf_asset_html: "%{item_description} on a new body type"
pet_type_html: "%{pet_type_description} for the first time" pet_type_html: "%{pet_type_description} for the first time"
pet_state_html: "a new pose for %{pet_type_description}" pet_state_html: "a new pose for %{pet_type_description}"
alt_style_html: "a new NC Style of the %{alt_style_name}" alt_style_html: "a new Alt Style of the %{alt_style_name}"
contribution: contribution:
description_html: "%{user_link} showed us %{contributed_description}" description_html: "%{user_link} showed us %{contributed_description}"
@ -678,15 +678,14 @@ en:
item_search: item_search:
placeholder: find an item… placeholder: find an item…
submit: search submit: search
rainbow_pool: modeling_hub:
tagline: Explore your options! tagline: Found something?
description: description:
Browse the colors you can paint your pets, and the "style" options Enter a pet's name here and we'll keep a copy of what it's wearing.
from the NC Mall! Thanks so much!
filters: load_pet:
species: Species… placeholder: model a pet…
color: Color… submit: submit
submit: Go
latest_contribution: latest_contribution:
header: Contributions header: Contributions
description_html: "%{user_link} showed us %{contributed_description}. description_html: "%{user_link} showed us %{contributed_description}.
@ -757,6 +756,10 @@ en:
load: load:
not_found: We couldn't find a pet by that name. Is it spelled correctly? not_found: We couldn't find a pet by that name. Is it spelled correctly?
asset_download_error:
We found the pet and what it's wearing, but couldn't download the
associated media files. Maybe Neopets is down, or changed their
firewall rules? Please try again later!
pet_download_error: pet_download_error:
We couldn't connect to Neopets to look up the pet. Maybe they're down. We couldn't connect to Neopets to look up the pet. Maybe they're down.
Please try again later! Please try again later!

View file

@ -116,7 +116,8 @@ es:
submit: Guardar submit: Guardar
edit: Editar edit: Editar
delete: Eliminar delete: Eliminar
delete_confirmation: ¿Estás seguro/a que quieres eliminar la lista "%{list_name}"? delete_confirmation:
¿Estás seguro/a que quieres eliminar la lista "%{list_name}"?
empty: Esta lista está vacía. empty: Esta lista está vacía.
edit: edit:
title: Editando la lista "%{list_name}" title: Editando la lista "%{list_name}"
@ -452,6 +453,12 @@ es:
item_search: item_search:
placeholder: buscar un objeto... placeholder: buscar un objeto...
submit: buscar submit: buscar
modeling_hub:
tagline: ¿Has encontrado algo?
description: Si no encuentras un objeto y sabes de un pet que lo lleve ¡Escribe su nombre aquí!
load_pet:
placeholder: desfilar con un pet...
submit: enviar
latest_contribution: latest_contribution:
header: Contribuciones header: Contribuciones
description_html: "%{user_link} nos ha mostrado %{contributed_description}. ¡Muchas gracias, %{user_link}!" description_html: "%{user_link} nos ha mostrado %{contributed_description}. ¡Muchas gracias, %{user_link}!"
@ -496,6 +503,7 @@ es:
submission_success: "%{points} puntos" submission_success: "%{points} puntos"
load: load:
not_found: No hemos podido encontrar a un pet con ese nombre. ¿Lo has escrito correctamente? not_found: No hemos podido encontrar a un pet con ese nombre. ¿Lo has escrito correctamente?
asset_download_error: Hemos encontrado el pet que intentas vestir, pero no hemos podido descargar las imágenes que lo asocian. Posiblemente Neopets está caído. ¡Por favor inténtalo de nuevo más tarde!
pet_download_error: No hemos podido conectar con Neopets para ver tu pet. Posiblemente el servidor de Neopets se ha caído. ¡Por favor inténtalo de nuevo más tarde! pet_download_error: No hemos podido conectar con Neopets para ver tu pet. Posiblemente el servidor de Neopets se ha caído. ¡Por favor inténtalo de nuevo más tarde!
users: users:
index: index:

View file

@ -114,7 +114,8 @@ pt:
submit: Salvar submit: Salvar
edit: Editar edit: Editar
delete: Excluir delete: Excluir
delete_confirmation: Você tem certeza que deseja excluir "%{list_name}"? delete_confirmation:
Você tem certeza que deseja excluir "%{list_name}"?
empty: Esta lista está vazia. empty: Esta lista está vazia.
edit: edit:
title: Editando lista "%{list_name}" title: Editando lista "%{list_name}"
@ -448,6 +449,12 @@ pt:
item_search: item_search:
placeholder: Procurar um item… placeholder: Procurar um item…
submit: Vai! submit: Vai!
modeling_hub:
tagline: Encontrou alguma coisa?
description: Digite o nome do pet aqui e nós vamos copiar o que ele está vestindo. Muito Obrigado.
load_pet:
placeholder: modele um pet…
submit: Enviar
latest_contribution: latest_contribution:
header: Contribuições header: Contribuições
description_html: "%{user_link} nos mostrou %{contributed_description}. Obrigado, %{user_link}!" description_html: "%{user_link} nos mostrou %{contributed_description}. Obrigado, %{user_link}!"
@ -490,6 +497,7 @@ pt:
submission_success: "%{points} pontos" submission_success: "%{points} pontos"
load: load:
not_found: Não foi possível achar um pet com esse nome. Está escrito corretamente? not_found: Não foi possível achar um pet com esse nome. Está escrito corretamente?
asset_download_error: Nós achamos o pet e o que ele está vestindo, mas não foi possível baixar os dados. Talvez Neopets esteja fora do ar. Por favor tente mais tarde!
pet_download_error: Nós não conseguimos conectar ao Neopets para achar o pet. Talvez eles estejam fora do ar. Por favor tente mais tarde! pet_download_error: Nós não conseguimos conectar ao Neopets para achar o pet. Talvez eles estejam fora do ar. Por favor tente mais tarde!
users: users:
index: index:

View file

@ -19,7 +19,7 @@ OpenneoImpressItems::Application.routes.draw do
get '/users/current-user/outfits', to: redirect('/your-outfits') get '/users/current-user/outfits', to: redirect('/your-outfits')
# Our customization data! Both the item pages, and JSON API endpoints. # Our customization data! Both the item pages, and JSON API endpoints.
resources :items, only: [:index, :show, :edit, :update] do resources :items, :only => [:index, :show] do
resources :trades, path: 'trades/:type', controller: 'item_trades', resources :trades, path: 'trades/:type', controller: 'item_trades',
only: [:index], constraints: {type: /offering|seeking/} only: [:index], constraints: {type: /offering|seeking/}
@ -35,16 +35,12 @@ OpenneoImpressItems::Application.routes.draw do
end end
resources :alt_styles, path: 'alt-styles', only: [:index] resources :alt_styles, path: 'alt-styles', only: [:index]
end end
resources :alt_styles, path: 'alt-styles', only: [:index]
resources :swf_assets, path: 'swf-assets', only: [:show] resources :swf_assets, path: 'swf-assets', only: [:show]
scope "rainbow-pool" do
resources :alt_styles, path: 'alt-styles', only: [:index, :edit, :update],
path: 'styles'
end
resources :pet_types, path: 'rainbow-pool', param: "name", resources :pet_types, path: 'rainbow-pool', param: "name",
only: [:index, :show] do only: [:index, :show] do
resources :pet_states, only: [:edit, :update], path: "appearances" resources :pet_states, only: [:show], path: "forms"
end end
get '/alt-styles', to: redirect('/rainbow-pool/styles')
# Loading and modeling pets! # Loading and modeling pets!
post '/pets/load' => 'pets#load', :as => :load_pet post '/pets/load' => 'pets#load', :as => :load_pet

View file

@ -1,5 +0,0 @@
class RemovePrankFromColors < ActiveRecord::Migration[7.2]
def change
remove_column "colors", "prank", :boolean, default: false, null: false
end
end

View file

@ -1,16 +0,0 @@
class AddCachedFieldsToItems < ActiveRecord::Migration[7.2]
def change
add_column :items, :cached_occupied_zone_ids, :string, null: false, default: ""
add_column :items, :cached_compatible_body_ids, :text, null: false, default: ""
reversible do |direction|
direction.up do
puts "Updating cached item fields for all items…"
Item.includes(:swf_assets).find_in_batches.with_index do |items, batch|
puts "Updating item batch ##{batch+1}"
items.each(&:update_cached_fields)
end
end
end
end
end

View file

@ -1,9 +0,0 @@
class AllowNullInItemsCachedFields < ActiveRecord::Migration[7.2]
def change
# This is a bit more compatible with ActiveRecord's `serialize` utility,
# which seems pretty insistent that empty arrays should be saved as `NULL`,
# rather than the empty string our serializer would return if called :(
change_column_null :items, :cached_compatible_body_ids, true
change_column_null :items, :cached_occupied_zone_ids, true
end
end

View file

@ -1,5 +0,0 @@
class AddTimestampsToPetStates < ActiveRecord::Migration[7.2]
def change
add_timestamps :pet_states, null: true
end
end

View file

@ -1,17 +0,0 @@
class IncreasePetTypeColorIdAndSpeciesIdLimit < ActiveRecord::Migration[7.2]
def change
reversible do |direction|
change_table :pet_types do |t|
direction.up do
t.change :color_id, :integer, null: false
t.change :species_id, :integer, null: false
end
direction.down do
t.change :color_id, :integer, limit: 1, null: false
t.change :species_id, :integer, limit: 1, null: false
end
end
end
end
end

View file

@ -1,21 +0,0 @@
class IncreaseIdLimits < ActiveRecord::Migration[7.2]
def change
reversible do |direction|
direction.up do
change_column :parents_swf_assets, :parent_id, :integer, null: false
change_column :parents_swf_assets, :swf_asset_id, :integer, null: false
change_column :pet_states, :pet_type_id, :integer, null: false
change_column :pets, :pet_type_id, :integer, null: false
change_column :swf_assets, :zone_id, :integer, null: false
end
direction.down do
change_column :parents_swf_assets, :parent_id, :integer, limit: 3, null: false
change_column :parents_swf_assets, :swf_asset_id, :integer, limit: 3, null: false
change_column :pet_states, :pet_type_id, :integer, limit: 3, null: false
change_column :pets, :pet_type_id, :integer, limit: 3, null: false
change_column :swf_assets, :zone_id, :integer, limit: 1, null: false
end
end
end
end

View file

@ -1,16 +0,0 @@
class AddCachedPredictedFullyModeledToItems < ActiveRecord::Migration[7.2]
def change
add_column :items, :cached_predicted_fully_modeled, :boolean,
default: false, null: false
reversible do |direction|
direction.up do
puts "Updating cached item fields for all items…"
Item.includes(:swf_assets).find_in_batches.with_index do |items, batch|
puts "Updating item batch ##{batch+1}"
items.each(&:update_cached_fields)
end
end
end
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_04_08_120359) do ActiveRecord::Schema[7.1].define(version: 2024_04_08_120359) do
create_table "users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t| create_table "users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t|
t.string "name", limit: 30, null: false t.string "name", limit: 30, null: false
t.string "encrypted_password", limit: 64 t.string "encrypted_password", limit: 64
@ -37,4 +37,5 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_08_120359) do
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true
end end
end end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_11_19_214543) do ActiveRecord::Schema[7.1].define(version: 2024_06_16_001002) do
create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "species_id", null: false t.integer "species_id", null: false
t.integer "color_id", null: false t.integer "color_id", null: false
@ -75,6 +75,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_19_214543) do
create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.boolean "basic" t.boolean "basic"
t.boolean "standard" t.boolean "standard"
t.boolean "prank", default: false, null: false
t.string "name", null: false t.string "name", null: false
t.string "pb_item_name" t.string "pb_item_name"
t.string "pb_item_thumbnail_url" t.string "pb_item_thumbnail_url"
@ -137,9 +138,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_19_214543) do
t.text "description", size: :medium, null: false t.text "description", size: :medium, null: false
t.string "rarity", default: "", null: false t.string "rarity", default: "", null: false
t.integer "dyeworks_base_item_id" t.integer "dyeworks_base_item_id"
t.string "cached_occupied_zone_ids", default: ""
t.text "cached_compatible_body_ids", default: ""
t.boolean "cached_predicted_fully_modeled", default: false, null: false
t.index ["dyeworks_base_item_id"], name: "index_items_on_dyeworks_base_item_id" t.index ["dyeworks_base_item_id"], name: "index_items_on_dyeworks_base_item_id"
t.index ["modeling_status_hint", "created_at", "id"], name: "items_modeling_status_hint_and_created_at_and_id" t.index ["modeling_status_hint", "created_at", "id"], name: "items_modeling_status_hint_and_created_at_and_id"
t.index ["modeling_status_hint", "created_at"], name: "items_modeling_status_hint_and_created_at" t.index ["modeling_status_hint", "created_at"], name: "items_modeling_status_hint_and_created_at"
@ -157,7 +155,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_19_214543) do
end end
create_table "modeling_logs", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "modeling_logs", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.datetime "created_at", precision: nil, default: -> { "current_timestamp()" }, null: false t.datetime "created_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false
t.text "log_json", size: :long, null: false t.text "log_json", size: :long, null: false
t.string "pet_name", limit: 128, null: false t.string "pet_name", limit: 128, null: false
end end
@ -197,8 +195,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_19_214543) do
end end
create_table "parents_swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "parents_swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "parent_id", null: false t.integer "parent_id", limit: 3, null: false
t.integer "swf_asset_id", null: false t.integer "swf_asset_id", limit: 3, null: false
t.string "parent_type", limit: 8, null: false t.string "parent_type", limit: 8, null: false
t.index ["parent_id", "parent_type"], name: "index_parents_swf_assets_on_parent_id_and_parent_type" t.index ["parent_id", "parent_type"], name: "index_parents_swf_assets_on_parent_id_and_parent_type"
t.index ["parent_id", "swf_asset_id"], name: "unique_parents_swf_assets", unique: true t.index ["parent_id", "swf_asset_id"], name: "unique_parents_swf_assets", unique: true
@ -212,7 +210,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_19_214543) do
end end
create_table "pet_states", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "pet_states", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "pet_type_id", null: false t.integer "pet_type_id", limit: 3, null: false
t.text "swf_asset_ids", size: :medium, null: false t.text "swf_asset_ids", size: :medium, null: false
t.boolean "female" t.boolean "female"
t.integer "mood_id" t.integer "mood_id"
@ -220,14 +218,12 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_19_214543) do
t.boolean "labeled", default: false, null: false t.boolean "labeled", default: false, null: false
t.boolean "glitched", default: false, null: false t.boolean "glitched", default: false, null: false
t.string "artist_neopets_username" t.string "artist_neopets_username"
t.datetime "created_at"
t.datetime "updated_at"
t.index ["pet_type_id"], name: "pet_states_pet_type_id" t.index ["pet_type_id"], name: "pet_states_pet_type_id"
end end
create_table "pet_types", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "pet_types", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "color_id", null: false t.integer "color_id", limit: 1, null: false
t.integer "species_id", null: false t.integer "species_id", limit: 1, null: false
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.integer "body_id", limit: 2, null: false t.integer "body_id", limit: 2, null: false
t.string "image_hash", limit: 8 t.string "image_hash", limit: 8
@ -241,7 +237,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_19_214543) do
create_table "pets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "pets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.string "name", limit: 20, null: false t.string "name", limit: 20, null: false
t.integer "pet_type_id", null: false t.integer "pet_type_id", limit: 3, null: false
t.index ["name"], name: "pets_name", unique: true t.index ["name"], name: "pets_name", unique: true
t.index ["pet_type_id"], name: "pets_pet_type_id" t.index ["pet_type_id"], name: "pets_pet_type_id"
end end
@ -254,7 +250,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_19_214543) do
t.string "type", limit: 7, null: false t.string "type", limit: 7, null: false
t.integer "remote_id", limit: 3, null: false t.integer "remote_id", limit: 3, null: false
t.text "url", size: :long, null: false t.text "url", size: :long, null: false
t.integer "zone_id", null: false t.integer "zone_id", limit: 1, null: false
t.text "zones_restrict", size: :medium, null: false t.text "zones_restrict", size: :medium, null: false
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.integer "body_id", limit: 2, null: false t.integer "body_id", limit: 2, null: false

View file

@ -442,21 +442,13 @@
mode: "755" mode: "755"
state: directory state: directory
- name: Remove 10min cron job to run `rails nc_mall:sync` - name: Create 10min cron job to run `rails nc_mall:sync`
become_user: impress become_user: impress
cron: cron:
state: absent
name: "Impress: sync NC Mall data" name: "Impress: sync NC Mall data"
minute: "*/10" minute: "*/10"
job: "bash -c 'source /etc/profile && source ~/.bash_profile && cd /srv/impress/current && bin/rails nc_mall:sync'" job: "bash -c 'source /etc/profile && source ~/.bash_profile && cd /srv/impress/current && bin/rails nc_mall:sync'"
- name: Create 10min cron job to run `rails neopets:import:nc_mall`
become_user: impress
cron:
name: "Impress: import NC Mall data"
minute: "*/10"
job: "bash -c 'source /etc/profile && source ~/.bash_profile && cd /srv/impress/current && bin/rails neopets:import:nc_mall'"
- name: Create weekly cron job to run `rails public_data:commit` - name: Create weekly cron job to run `rails public_data:commit`
become_user: impress become_user: impress
cron: cron:

View file

@ -1,37 +0,0 @@
require "async"
require "async/barrier"
require "async/http/internet/instance"
module DTIRequests
class << self
def get(url, headers = [], &block)
Async::HTTP::Internet.get(url, add_headers(headers), &block)
end
def post(url, headers = [], body = nil, &block)
Async::HTTP::Internet.post(url, add_headers(headers), body, &block)
end
def load_many(max_at_once: 10)
barrier = Async::Barrier.new
semaphore = Async::Semaphore.new(max_at_once, parent: barrier)
Sync do
block_return_value = yield semaphore
barrier.wait # Load all the subtasks.
block_return_value
ensure
barrier.stop # If any subtasks failed, cancel the rest.
end
end
private
def add_headers(headers)
if headers.none? { |(k, v)| k.downcase == "user-agent" }
headers += [["User-Agent", Rails.configuration.user_agent_for_neopets]]
end
headers
end
end
end

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