Compare commits

..

58 commits

Author SHA1 Message Date
c7bea666c9 Add the "body fit" field to the item editing page 2024-11-20 12:23:30 -08:00
f49f9f386d Add item editing fields to manually override an item to be an NC item 2024-11-20 12:13:33 -08:00
3937ba354f Add edit page for items, to set modeling status for done/glitchy items 2024-11-20 12:07:25 -08:00
388bb9a251 Oops, fix mistakes when extracting support-form class
I didn't include the alt styles call site when making the commit, and
also I named the file differently than other nearby files are named!
2024-11-20 11:24:56 -08:00
e846a75f7a Use modeling hints to remove some items from is:modeling searches
There's still plenty left, but we have 213 we "manually" marked as
"done" (I think I ran a batch job on everything Chips told me was on
the page and already done), and that should help a lot!
2024-11-20 11:22:33 -08:00
270b27c1d2 Extract alt style form CSS into a new "support-form" class
Gonna use this for item editing too, I think!
2024-11-20 11:16:46 -08:00
4cbac13df1 Remove careful SQL-selecting on homepage
This keeps causing missing-attribute crashes when I change things, and
I don't think the performance benefit is a big deal for how the page
currently runs, esp as we keep gathering more attributes? I feel like
`description` is the main "large" one we're omitting, and like. Shrug!
2024-11-20 10:44:33 -08:00
0261d02137 Downgrade most item validations to be *not nil*, rather than present
Been running into the item "Hanging Plushie Grundo Background Item" not
being modelable, because TNT seem to have left its description blank!

Let's be less picky about what data we take in, but keep the intention
of these validations: to ensure that *we* don't make a mistake and
forget a field when importing items!
2024-11-19 17:00:47 -08:00
e82c606ee8 Ah beans, fix a homepage crash from the modeling logic changes
Tbh I'm not sure how much this select clause is helping us, but w/e.
2024-11-19 16:49:20 -08:00
ed5b62e161 Use PetType's created_at to predict who an item might be compatible with
This is a basic attempt at the Vandagyre logic, but also things like
"Maraquan items released before the Maraquan X was released"!

I also added a new task, `rails items:update_cached_fields`, which needs
to be run after this change, because it affects the value of
`Item#predicted_fully_modeled?`.

Eyeballing the updated search results for `-is:modeled`, this feels
pretty close? I'm guessing it's not perfect (e.g. maybe a pet type we
got modeled late into its existence, or some items that just never did
fit a certain pet), but feels pretty good.

I also know we had the "modeling hints" override in Impress 2020, which
we aren't reading yet. We should probably take that into account here
too!
2024-11-19 16:41:50 -08:00
5472ccebef Add is:modeled query to items 2024-11-19 15:54:55 -08:00
f6f618c9d5 Add Item.is{_not}_modeled scopes, for use in search later
We're now caching `predicted_fully_modeled?` on the database record, so
we can query by it in the database!

I'm moving on from the model I did in Impress 2020, of writing really
big fancy single-source-of-truth queries based on the assets themselves.

I see the merit of that in terms of theoretical reliability, but in
practice I think it will be *more* reliable to have one *in-code*
definition of modeling status (which we need anyway for generating the
homepage modeling requests), and just save that in a queryable way.
2024-11-19 15:52:52 -08:00
39bed6b157 Make Item's update_cached_fields callback more reliable
In our tests, I discovered an unexpected behavior where calling
`item.swf_assets << swf_asset` wasn't updating computed fields
correctly.

This isn't something we actually do in-app, I think the modeling system
happened to trigger the callbacks in a way that still worked fine?

But I think this is a good idea for reliability, since caching is such
a notoriously difficult thing to get right anyway! And it makes our
tests simpler and clearer.

Specifically, `compatible_body_ids` references `swf_assets`, which, I'm
kinda surprised, *doesn't* include the newly-added asset yet when the
`ParentSwfAssetRelationship.after_save` hook runs while calling
`item.swf_assets << swf_asset`. Reloading it fixes this!
2024-11-19 14:26:06 -08:00
af5187edb6 Refactor item modeling spec, in anticipation of Item.is_modeled tests
I'm grouping some shared behaviors to pull into the different cases, so
that we can check the behaviors of a fully-modeled item vs a
not-fully-modeled item in *all* of the relevant cases.

Specifically, I'm planning to add `is:modeled` search filters, and
creating pending placeholder tests for them!
2024-11-19 13:44:31 -08:00
21eaf7b266 Fix silly variable scoping issue in item spec
Oh right, `@remote_id` is an instance variable so we can auto-increment
it over time, but `url` is just a derived value, and can just be local!
Silly!
2024-11-19 13:32:47 -08:00
91851bc340 Add tests for Maraquan item modeling predictions
I also added a test I forgot for the standard case: when you've modeled
each individual species.
2024-11-19 13:31:34 -08:00
3e7d27eaa3 Add a reminder to write modeling prediction tests for special colors 2024-11-19 12:18:17 -08:00
f7109e398a Better handle modeling predictions for items with *no* data
This doesn't generally happen, but did the other day when I rolled back
some of the database's SWF asset records but kept the items—and it was
a bit confusing that the homepage marked them as fully modeled!
2024-11-19 12:15:21 -08:00
f90380c4e6 Add tests for item modeling predictions
The stuff on the homepage! I'm also thinking about how to use these for
better discovery of items that need more modeling.
2024-11-19 12:10:49 -08:00
218dc5b6f9 Improve Solargraph LSP in our spec files
The main thing is that I was getting "RequireNotFound" warnings for
`require 'rails_helper'`, because the LSP seems unaware of how RSpec
offers `spec/` as a root for requires.

I think the `require_relative` is clearer anyway, I'm decently
satisfied with it. And if I decide it's too much ugly, we can try
something else in the Solargraph config or something sometime!
2024-11-19 11:28:36 -08:00
bc0097850d Say "NC Style" instead of "Alt Style" in contribution descriptions
Just for increased consistency!
2024-11-16 12:21:43 -08:00
ec0b8d9cb9 Only prompt for Neologin cookie once when running rails neopets:import 2024-11-16 12:11:13 -08:00
a57b3629db Refactor Neopets import tasks all into a neopets:import namespace
and with a `rails neopets:import` task you can call to do them all at
once!

I'm gonna do some other stuff here too to make `neopets:import` easier
to call all in one go, like prompting for the Neologin cookie just
once at the start.

Note that this changes the cron setup, so you gotta run
`bin/deploy:setup` after this deploys!
2024-11-16 11:58:43 -08:00
1d1dc15320 Remove some more incorrect limits on ID fields in the database
I'm running into this with the automated tests and the fixtures I think
sometimes using large auto-generated IDs?

But the point is, our tables generally use Rails's default `:integer`
size for its IDs, and then columns that reference them are *smaller*,
which is… not correct stuff, y'know?

So I figure, let's just expand the columns. We don't have enough data
that being real picky about the integer sizes matters, so let's keep it
simple and more obviously correct.
2024-11-15 20:39:38 -08:00
b6c21dfe40 Oops, fix sort order for alt styles
Oh huh, when doing Rainbow Pool stuff, I put the ordering in the wrong
place! It's a sensible ordering for the Rainbow Pool page, but not so
much for the JSON view!
2024-11-15 20:28:38 -08:00
c4a7e7916f Fall back to blank image if alt style has no preview image
This is currently crashing the Rainbow Pool when the Anniversary Techo
would appear, because the asset seems to be missing? The SWF doesn't
seem to exist, nor does its manifest.
2024-11-15 20:04:45 -08:00
217d25edab Handle new colors/species in the Rainbow Pool
Oh right, yeah, we like to do things gracefully around here when
there's no corresponding color/species record yet!

Paying more attention to this, I'm thinking like… it could be a cool
idea to, in modeling, *create* the new color/species record, and just
not have all the attributes filled in yet? Especially now that we're
less dependent on attributes like `standard` to be set for correct
functioning.

But for now, we follow the same strategy we do elsewhere in the app: a
pet type can have `color_id` and `species_id` that don't correspond to
a real record, and we cover over that smoothly.
2024-11-15 19:56:07 -08:00
dd213e8078 Increase the maximum value for pet types' color ID and species ID
Oh dang, we're on color #120 now, and looks like our maximum value is
127. Let's expand that!

I noticed this because I'm writing tests for some stuff, and used "456"
as a placeholder ID number, and it just fully did not work, and I'm
like. Oh.
2024-11-15 19:29:13 -08:00
c5995a2bd1 Oops, turn modeling back on
Huh, I guess when I reapplied my refactors to modeling disabling the
other day, I didn't notice that I turned it off in production. And I
guess I didn't deploy this at the time cuz it's just refactors, but
when I deployed other changes yesterday this came with it. Whoops!
2024-11-15 17:37:54 -08:00
1ad3ea8f96 Add rails alt_styles:import task to import info from Styling Studio
Yay whew! Magic time!
2024-11-14 19:03:44 -08:00
b245690a60 Oops, fix bug when loading species with no alt styles in the Studio 2024-11-14 19:03:06 -08:00
3ed1c46b64 Add NCMall.load_styles method, not yet used
I only now thought through that I can scrape these instead of enter
them manually, similar to how we did our Rainbow Pool scraper… hooray!

I'm actually writing tests for stuff too, wowie!
2024-11-14 18:45:08 -08:00
9e3ce74ed5 Auto-focus series name when labeling alt styles
Helpful when doing a bunch in a row (like today's big release!)
2024-11-14 17:14:03 -08:00
5f31e38428 Reapply "Extract modeling ViewerData class into new Pet::ModelingSnapshot file"
This change was modified a bit after cherry-picking, to no longer
include the broken changes to item modeling in 9eaee4a.

(cherry picked from commit 90407403ba)
2024-11-10 11:43:54 -08:00
8f9daf4d52 Reapply "Use our IntegerSet serializer for PetState#swf_asset_ids"
(cherry picked from commit 242b85470d)
2024-11-10 11:41:06 -08:00
3242981eb2 Reapply changes to how disabling modeling works
```shell
git cherry-pick d82c7f817a --no-commit
```
2024-11-10 11:39:51 -08:00
54b25ef08e Reintroduce some of our modeling refactors, without touching items
Okay so, when we reverted a buncha stuff in e3d196f, it was in response
to a bug where item modeling data was getting deleted. And I was tired,
and just took a big simple hammer to it of reverting all the modeling
refactors.

Here, we reintroduce *some* of them: the biology ones before the item
bug. And tests still pass, and in fact I can un-pending some of them!

I might also try to reapply the change where we extract it all into a
new file, but without the item parts.

```shell
git cherry-pick --no-commit 13ceec8fcc
git cherry-pick --no-commit f81415d327
git cherry-pick --no-commit c03e7446e3
git cherry-pick --no-commit 52ca41dbff
```
2024-11-10 11:36:23 -08:00
e4e81f0694 Update modeling bug announcement, now that things are working again
Also, while we're here! To restore the lost data, I:

1. Downloaded this scheduled public data backup, which was taken
   thankfully the day before we updated modeling code!
   https://impress.openneo.net/public-data/2024-11-03T08_15_02Z-scheduled.sql.gz
2. Trimmed it just to the section about the `parents_swf_assets` table:
   dropping it, then rebuilding it from scratch.
3. Ran this modified backup SQL dump on the production server.
4. Ran the code from `db/migrate/20241001052510_add_cached_fields_to_items.rb`
   to bring items' cached fields back into the correct state.

I also had to fix some errors in the item data that prevented some
items from passing the latest validations:

```rb
Item.where(rarity: "").update_all(rarity: "???")
Item.where(description: "").update_all(description: "???")
Item.where(zones_restrict: "").update_all(zones_restrict: "00000
00000000000000000000000000000000000000000000000")
```
2024-11-06 14:34:15 -08:00
e3d196fe87 Revert modeling refactors to the old modeling that worked!
Because we ended up with such a big error, and it doesn't have an easy
fix, I'm wrapping up today by reverting the entire set of refactors
we've done lately, so modeling in production can continue while we
improve this code further over time.

I generated this commit by hand-picking the refactor-y commits
recently, running `git revert --no-commit <hash>` in reverse order,
then manually updating `pet_spec.rb` to reflect the state of the code:
passing the most important behavioral tests, but no longer passing one
of the kinds of annoyances I *did* fix in the new code.

```shell
git revert --no-commit 48c1a58df9
git revert --no-commit 42e7eabdd8
git revert --no-commit d82c7f817a
git revert --no-commit 5264947608
git revert --no-commit 90407403ba
git revert --no-commit 242b85470d
git revert --no-commit 9eaee4a2d4
git revert --no-commit 52ca41dbff
git revert --no-commit c03e7446e3
git revert --no-commit f81415d327
git revert --no-commit 13ceec8fcc
```
2024-11-06 14:31:16 -08:00
0b3dd02323 Add failing test for modeling bug where we break existing connections
As I'm writing out my solution for this, I'm almost wondering if it's
time for the refactor I've been Theoretically Planning Someday, to move
items to a real `ItemAppearance` model in the database similar to
`PetState`… Hmm hmm hmm…

For now though, I'm taking a break!
2024-11-06 14:08:32 -08:00
48c1a58df9 Fix new bug where re-modeling a background would reset it from ID 0
This bug never made it into production I think, it was a consequence of
some of how I refactored stuff in the recent changes? I think??

But yeah, I refactor how we manage `SwfAsset#body_id`, to be a bit more
explicit about when and how it can change, instead of the weird
callbacks that tbqh have bit us too often…
2024-11-06 13:48:01 -08:00
42e7eabdd8 Fix modeling bug where compatible_body_ids field was not updating
Ah right, the callbacks in `ParentSwfAssetRelationship` don't get
called when Rails does automatic join-model management stuff. We need
the `Item` to call its `update_cached_fields` callback itself, too!

When fixing this, I found a new bug that arose, in how we infer
`body_id` for assets that fit all pets. Fixing that next!
2024-11-06 13:39:32 -08:00
a208fca8d2 Improve modeling tests for records that shouldn't change
This gives better output when they fail, and also avoids spurious
failures like when an array for `cached_compatible_body_ids` is replaced
by an identical one! (I'm running into this right now, and yeah, it
helps a lot lol)
2024-11-06 13:04:50 -08:00
3ac89e830e Announcement about modeling being broken 2024-11-06 11:55:05 -08:00
d82c7f817a Disable modeling in production, while we investigate errors
Hmm, I think I made a mistake on `modeling_snapshot.rb:69`: I'm
assigning the *entire* `item.swf_assets` relation to *just* the assets
for the new model of it, which breaks all the other connections.

First, I'm disabling modeling. Then, I'll restore a backup. Then, I'll
write tests for that case, and fix it up!
2024-11-06 11:54:28 -08:00
5264947608 Minor tweaks to modeling private methods 2024-11-03 12:24:54 -08:00
90407403ba Extract modeling ViewerData class into new Pet::ModelingSnapshot file
Both extracted and renamed!
2024-11-03 12:23:51 -08:00
242b85470d Use our IntegerSet serializer for PetState#swf_asset_ids 2024-11-03 12:16:27 -08:00
43717e2535 Remove unused PetState#reassign_duplicates! 2024-11-03 12:07:57 -08:00
bc1f7152bf Remove unused SwfAsset.from_wardrobe_link_params 2024-11-03 12:07:23 -08:00
9eaee4a2d4 Refactor item modeling
Simpler, more encapsulated, and fixes the pending jank stuff in the
tests!
2024-11-03 12:05:37 -08:00
52ca41dbff Extract biology processing from AltStyle into Pet
I'm trying to pull more of the modeling code out of the individual
classes, and into this encapsulated preprocessing, so it's a lot more
in-one-place!
2024-11-03 11:46:29 -08:00
c03e7446e3 Refactor out biology assets in modeling code a bit 2024-11-03 11:41:18 -08:00
6402e5abc3 Remove the pending marker on some modeling tests 2024-11-02 21:39:34 -07:00
f81415d327 Refactor modeling viewer data handling into a new ViewerData class 2024-11-02 21:34:19 -07:00
13ceec8fcc Simplify modeling code for biology data
We're leaning more into Rails collection management and autosave stuff!
2024-11-02 21:15:12 -07:00
40765c729e Remove unused Pet.with_pet_type_color_ids scope 2024-11-02 20:21:59 -07:00
d26f3a7598 Add tests for modeling alt style assets 2024-11-02 20:15:35 -07:00
59 changed files with 1667 additions and 366 deletions

1
.gitignore vendored
View file

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

View file

@ -84,10 +84,13 @@ gem "sentry-rails", "~> 5.12"
gem "shell", "~> 0.8.1" gem "shell", "~> 0.8.1"
# For workspace autocomplete. # For workspace autocomplete.
gem "solargraph", "~> 0.50.0", group: :development group :development do
gem "solargraph-rails", "~> 1.1", group: :development gem "solargraph", "~> 0.50.0"
gem "solargraph-rails", "~> 1.1"
end
# For automated tests. # For automated tests.
group :development, :test do group :development, :test do
gem "rspec-rails", "~> 7.0" gem "rspec-rails", "~> 7.0"
gem "webmock", "~> 3.24", group: :test
end end

View file

@ -128,6 +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)
@ -182,6 +185,7 @@ 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)
@ -496,6 +500,10 @@ 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)
@ -549,6 +557,7 @@ 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

View file

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

View file

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

View file

@ -67,13 +67,20 @@
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
.user-lists-form-opener display: flex
&::after gap: 1em
a::after
content: " " content: " "
.user-lists-form .user-lists-form

View file

@ -15,9 +15,7 @@ class AltStylesController < ApplicationController
@color = find_color @color = find_color
@species = find_species @species = find_species
@alt_styles = @all_alt_styles.includes(:swf_assets). @alt_styles = @all_alt_styles.includes(:swf_assets)
by_creation_date.order(:color_id, :species_id, :series_name).
paginate(page: params[:page], per_page: 30)
@alt_styles.where!(series_name: @series_name) if @series_name.present? @alt_styles.where!(series_name: @series_name) if @series_name.present?
@alt_styles.merge!(@color.alt_styles) if @color @alt_styles.merge!(@color.alt_styles) if @color
@alt_styles.merge!(@species.alt_styles) if @species @alt_styles.merge!(@species.alt_styles) if @species
@ -27,9 +25,16 @@ class AltStylesController < ApplicationController
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 { render } format.html {
@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 {
render json: @alt_styles.includes(swf_assets: [:zone]).as_json( @alt_styles = @alt_styles.includes(swf_assets: [:zone]).
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: {

View file

@ -1,5 +1,6 @@
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
@ -112,6 +113,21 @@ 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"
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(",")
@ -164,6 +180,15 @@ 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

@ -50,10 +50,7 @@ class OutfitsController < ApplicationController
@colors = Color.alphabetical @colors = Color.alphabetical
@species = Species.alphabetical @species = Species.alphabetical
newest_items = Item.newest. newest_items = Item.newest.limit(18)
select(:id, :name, :updated_at, :thumbnail_url, :rarity_index,
:is_manually_nc, :cached_compatible_body_ids)
.limit(18)
@newest_modeled_items, @newest_unmodeled_items = @newest_modeled_items, @newest_unmodeled_items =
newest_items.partition(&:predicted_fully_modeled?) newest_items.partition(&:predicted_fully_modeled?)

View file

@ -17,7 +17,7 @@ class PetStatesController < ApplicationController
protected protected
def find_pet_state def find_pet_state
@pet_type = PetType.matching_name_param(params[:pet_type_name]).first! @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])
end end

View file

@ -70,9 +70,7 @@ class PetTypesController < ApplicationController
color_id: params[:color_id], color_id: params[:color_id],
) )
elsif params[:name] elsif params[:name]
color_name, _, species_name = params[:name].rpartition("-") PetType.find_by_param!(params[:name])
raise ActiveRecord::RecordNotFound if species_name.blank?
PetType.matching_name(color_name, species_name).first!
else else
raise "expected params: species_id and color_id, or name" raise "expected params: species_id and color_id, or name"
end end

View file

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

View file

@ -1,5 +1,5 @@
module OutfitsHelper module OutfitsHelper
LAST_DAY_OF_ANNOUNCEMENT = Date.parse("2024-10-21") LAST_DAY_OF_ANNOUNCEMENT = Date.parse("2024-11-08")
def show_announcement? def show_announcement?
Date.today <= LAST_DAY_OF_ANNOUNCEMENT Date.today <= LAST_DAY_OF_ANNOUNCEMENT
end end

View file

@ -62,11 +62,10 @@ class AltStyle < ApplicationRecord
"#{series_name} #{name}" "#{series_name} #{name}"
end end
EMPTY_IMAGE_URL = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
def preview_image_url def preview_image_url
swf_asset = swf_assets.first # Use the image URL for the first asset. Or, fall back to an empty image.
return nil if swf_asset.nil? swf_assets.first&.image_url || EMPTY_IMAGE_URL
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.
@ -74,15 +73,6 @@ class AltStyle < ApplicationRecord
Item.appearances_for(items, self, ...) Item.appearances_for(items, self, ...)
end end
def biology=(biology)
# TODO: This is very similar to what `PetState` does, but like… much much
# more compact? Idk if I'm missing something, or if I was just that much
# more clueless back when I wrote it, lol 😅
self.swf_assets = biology.values.map do |asset_data|
SwfAsset.from_biology_data(self.body_id, asset_data)
end
end
# At time of writing, most batches of Alt Styles thumbnails used a simple # At time of writing, most batches of Alt Styles thumbnails used a simple
# pattern for the item thumbnail URL, but that's not always the case anymore. # pattern for the item thumbnail URL, but that's not always the case anymore.
# For now, let's keep using this format as the default value when creating a # For now, let's keep using this format as the default value when creating a
@ -103,6 +93,10 @@ class AltStyle < ApplicationRecord
end end
end end
def real_thumbnail_url?
thumbnail_url != DEFAULT_THUMBNAIL_URL
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

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

View file

@ -23,8 +23,16 @@ class Item < ApplicationRecord
has_many :dyeworks_variants, class_name: "Item", has_many :dyeworks_variants, class_name: "Item",
inverse_of: :dyeworks_base_item inverse_of: :dyeworks_base_item
validates_presence_of :name, :description, :thumbnail_url, :rarity, :price, # We require a name field. A number of other fields must be *specified*: they
:zones_restrict # 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
@ -65,6 +73,12 @@ class Item < ApplicationRecord
where('description NOT LIKE ?', where('description NOT LIKE ?',
'%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%') '%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%')
} }
scope :is_modeled, -> {
where(cached_predicted_fully_modeled: true)
}
scope :is_not_modeled, -> {
where(cached_predicted_fully_modeled: false)
}
scope :occupies, ->(zone_label) { scope :occupies, ->(zone_label) {
Zone.matching_label(zone_label). Zone.matching_label(zone_label).
map { |z| occupies_zone_id(z.id) }.reduce(none, &:or) map { |z| occupies_zone_id(z.id) }.reduce(none, &:or)
@ -263,8 +277,19 @@ class Item < ApplicationRecord
end end
def update_cached_fields def update_cached_fields
# First, clear out some cached instance variables we use for performance,
# to ensure we recompute the latest values.
@predicted_body_ids = nil
@predicted_missing_body_ids = nil
# We also need to reload our associations, so they include any new records.
swf_assets.reload
# Finally, compute and save our cached fields.
self.cached_occupied_zone_ids = occupied_zone_ids self.cached_occupied_zone_ids = occupied_zone_ids
self.cached_compatible_body_ids = compatible_body_ids(use_cached: false) self.cached_compatible_body_ids = compatible_body_ids(use_cached: false)
self.cached_predicted_fully_modeled =
predicted_fully_modeled?(use_cached: false)
self.save! self.save!
end end
@ -278,8 +303,16 @@ class Item < ApplicationRecord
write_attribute('species_support_ids', replacement) write_attribute('species_support_ids', replacement)
end end
def modeling_hinted_done?
modeling_status_hint == "done" || modeling_status_hint == "glitchy"
end
def predicted_body_ids def predicted_body_ids
@predicted_body_ids ||= if compatible_body_ids.include?(0) @predicted_body_ids ||= if modeling_hinted_done?
# If we've manually set this item to no longer report as needing modeling,
# predict that the current bodies are all of the compatible bodies.
compatible_body_ids
elsif compatible_body_ids.include?(0)
# 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
@ -291,6 +324,11 @@ class Item < ApplicationRecord
# This might just be a species-specific item. Let's be conservative in # This might just be a species-specific item. Let's be conservative in
# our prediction, though we'll revise it if we see another body ID. # our prediction, though we'll revise it if we see another body ID.
compatible_body_ids compatible_body_ids
elsif compatible_body_ids.size == 0
# If somehow we have this item, but not any modeling data for it (weird!),
# consider it to fit all standard pet types until shown otherwise.
PetType.basic.released_before(released_at_estimate).
distinct.pluck(:body_id).sort
else else
# First, find our compatible pet types, then pair each body ID with its # First, find our compatible pet types, then pair each body ID with its
# color. (As an optimization, we omit standard colors, other than the # color. (As an optimization, we omit standard colors, other than the
@ -324,10 +362,17 @@ class Item < ApplicationRecord
compatible_color_ids_by_body_id.values. compatible_color_ids_by_body_id.values.
any? { |v| v.include?("basic") && (v & modelable_color_ids).empty? } any? { |v| v.include?("basic") && (v & modelable_color_ids).empty? }
# Get all body IDs for the colors we decided are modelable. # Filter to pet types that match the colors that seem compatible.
predicted_pet_types = predicted_pet_types =
(basic_is_modelable ? PetType.basic : PetType.none). (basic_is_modelable ? PetType.basic : PetType.none).
or(PetType.where(color_id: modelable_color_ids)) or(PetType.where(color_id: modelable_color_ids))
# Only include species that were released when this item was. If we don't
# know our creation date (we don't have it for some old records), assume
# it's pretty old.
predicted_pet_types.merge! PetType.released_before(released_at_estimate)
# Get all body IDs for the pet types we decided are modelable.
predicted_pet_types.distinct.pluck(:body_id).sort predicted_pet_types.distinct.pluck(:body_id).sort
end end
end end
@ -379,7 +424,8 @@ class Item < ApplicationRecord
body_ids_by_species_by_color body_ids_by_species_by_color
end end
def predicted_fully_modeled? def predicted_fully_modeled?(use_cached: true)
return cached_predicted_fully_modeled? if use_cached
predicted_missing_body_ids.empty? predicted_missing_body_ids.empty?
end end
@ -387,6 +433,12 @@ class Item < ApplicationRecord
compatible_body_ids.size.to_f / predicted_body_ids.size compatible_body_ids.size.to_f / predicted_body_ids.size
end end
# We estimate the item's release time as either when we first saw it, or 2010
# if it's so old that we don't have a record.
def released_at_estimate
created_at || Time.new(2010)
end
def as_json(options={}) def as_json(options={})
super({ super({
only: [:id, :name, :description, :thumbnail_url, :rarity_index], only: [:id, :name, :description, :thumbnail_url, :rarity_index],

View file

@ -132,6 +132,8 @@ 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
@ -346,6 +348,14 @@ class Item
self.new Item.is_not_pb, '-is:pb' self.new Item.is_not_pb, '-is:pb'
end end
def self.is_modeled
self.new Item.is_modeled, 'is:modeled'
end
def self.is_not_modeled
self.new Item.is_not_modeled, '-is:modeled'
end
private private
# Add quotes around the value, if needed. # Add quotes around the value, if needed.

View file

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

View file

@ -0,0 +1,104 @@
# 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

@ -6,18 +6,17 @@ class PetState < ApplicationRecord
has_many :contributions, :as => :contributed, has_many :contributions, :as => :contributed,
:inverse_of => :contributed # in case of duplicates being merged :inverse_of => :contributed # in case of duplicates being merged
has_many :outfits has_many :outfits
has_many :parent_swf_asset_relationships, :as => :parent, has_many :parent_swf_asset_relationships, :as => :parent
:autosave => false
has_many :swf_assets, :through => :parent_swf_asset_relationships has_many :swf_assets, :through => :parent_swf_asset_relationships
serialize :swf_asset_ids, coder: Serializers::IntegerSet, type: Array
belongs_to :pet_type belongs_to :pet_type
delegate :species_id, :species, :color_id, :color, to: :pet_type delegate :species_id, :species, :color_id, :color, to: :pet_type
alias_method :swf_asset_ids_from_association, :swf_asset_ids alias_method :swf_asset_ids_from_association, :swf_asset_ids
attr_writer :parent_swf_asset_relationships_to_update
# 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, -> {
order(Arel.sql( order(Arel.sql(
@ -95,109 +94,16 @@ class PetState < ApplicationRecord
end end
end end
def reassign_children_to!(main_pet_state)
self.contributions.each do |contribution|
contribution.contributed = main_pet_state
contribution.save
end
self.outfits.each do |outfit|
outfit.pet_state = main_pet_state
outfit.save
end
ParentSwfAssetRelationship.where(ParentSwfAssetRelationship.arel_table[:parent_id].eq(self.id)).delete_all
end
def reassign_duplicates!
raise "This may only be applied to pet states that represent many duplicate entries" unless duplicate_ids
pet_states = duplicate_ids.split(',').map do |id|
PetState.find(id.to_i)
end
main_pet_state = pet_states.shift
pet_states.each do |pet_state|
pet_state.reassign_children_to!(main_pet_state)
pet_state.destroy
end
end
def sort_swf_asset_ids!
self.swf_asset_ids = swf_asset_ids_array.sort.join(',')
end
def swf_asset_ids
self['swf_asset_ids']
end
def swf_asset_ids_array
swf_asset_ids.split(',').map(&:to_i)
end
def swf_asset_ids=(ids)
self['swf_asset_ids'] = ids
end
def handle_assets!
@parent_swf_asset_relationships_to_update.each do |rel|
rel.swf_asset.save!
rel.save!
end
end
def to_param def to_param
"#{id}-#{pose.split('_').map(&:capitalize).join('-')}" "#{id}-#{pose.split('_').map(&:capitalize).join('-')}"
end end
def self.from_pet_type_and_biology_info(pet_type, info) # Because our column is named `swf_asset_ids`, we need to ensure writes to
swf_asset_ids = [] # it go to the attribute, and not the thing ActiveRecord does of finding the
info.each do |zone_id, asset_info| # relevant `swf_assets`.
if zone_id.present? && asset_info # TODO: Consider renaming the column to `cached_swf_asset_ids`?
swf_asset_ids << asset_info[:part_id].to_i def swf_asset_ids=(new_swf_asset_ids)
end write_attribute(:swf_asset_ids, new_swf_asset_ids)
end
swf_asset_ids_str = swf_asset_ids.sort.join(',')
if pet_type.new_record?
pet_state = self.new :swf_asset_ids => swf_asset_ids_str
else
pet_state = self.find_or_initialize_by(
pet_type_id: pet_type.id,
swf_asset_ids: swf_asset_ids_str
)
end
existing_swf_assets = SwfAsset.biology_assets.includes(:zone).
where(remote_id: swf_asset_ids)
existing_swf_assets_by_id = {}
existing_swf_assets.each do |swf_asset|
existing_swf_assets_by_id[swf_asset.remote_id] = swf_asset
end
existing_relationships_by_swf_asset_id = {}
unless pet_state.new_record?
pet_state.parent_swf_asset_relationships.each do |relationship|
existing_relationships_by_swf_asset_id[relationship.swf_asset_id] = relationship
end
end
pet_state.pet_type = pet_type # save the second case from having to look it up by ID
relationships = []
info.each do |zone_id, asset_info|
if zone_id.present? && asset_info
swf_asset_id = asset_info[:part_id].to_i
swf_asset = existing_swf_assets_by_id[swf_asset_id]
unless swf_asset
swf_asset = SwfAsset.new
swf_asset.remote_id = swf_asset_id
end
swf_asset.origin_biology_data = asset_info
swf_asset.origin_pet_type = pet_type
relationship = existing_relationships_by_swf_asset_id[swf_asset.id]
unless relationship
relationship ||= ParentSwfAssetRelationship.new
relationship.parent = pet_state
relationship.swf_asset_id = swf_asset.id
end
relationship.swf_asset = swf_asset
relationships << relationship
end
end
pet_state.parent_swf_asset_relationships_to_update = relationships
pet_state
end end
private private

View file

@ -15,10 +15,6 @@ 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 :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])
} }
@ -30,6 +26,10 @@ 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)
}
def self.random_basic_per_species(species_ids) def self.random_basic_per_species(species_ids)
random_pet_types = [] random_pet_types = []
@ -57,6 +57,14 @@ 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
@ -71,11 +79,6 @@ 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
@ -113,7 +116,7 @@ class PetType < ApplicationRecord
end end
def to_param def to_param
"#{color.human_name}-#{species.human_name}" "#{possibly_new_color.to_param}-#{possibly_new_species.to_param}"
end end
def fully_labeled? def fully_labeled?
@ -133,6 +136,15 @@ class PetType < ApplicationRecord
pet_states.count { |ps| ps.pose == "UNKNOWN" } pet_states.count { |ps| ps.pose == "UNKNOWN" }
end end
def self.find_by_param!(param)
raise ActiveRecord::RecordNotFound unless param.include?("-")
color_param, _, species_param = param.rpartition("-")
where(
color_id: Color.param_to_id(color_param),
species_id: Species.param_to_id(species_param),
).first!
end
def self.basic_body_ids def self.basic_body_ids
PetType.basic.distinct.pluck(:body_id) PetType.basic.distinct.pluck(:body_id)
end end

View file

@ -16,6 +16,10 @@ 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.)
@ -26,4 +30,8 @@ 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

@ -320,14 +320,6 @@ 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)

View file

@ -45,6 +45,37 @@ 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
INTERNET.post(
STYLING_STUDIO_URL,
headers: [
["User-Agent", Rails.configuration.user_agent_for_neopets],
["Content-Type", "application/x-www-form-urlencoded"],
["Cookie", "neologin=#{neologin}"],
["X-Requested-With", "XMLHttpRequest"],
],
body: {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)

View file

@ -13,7 +13,7 @@
= image_tag @alt_style.preview_image_url, class: "alt-style-preview" = image_tag @alt_style.preview_image_url, class: "alt-style-preview"
= form_with model: @alt_style, class: "alt-style-form" do |f| = form_with model: @alt_style, class: "support-form" do |f|
- if @alt_style.errors.any? - if @alt_style.errors.any?
%p %p
Could not save: Could not save:
@ -22,7 +22,7 @@
%li= error.full_message %li= error.full_message
%fieldset %fieldset
= f.label :real_series_name, "Series" = f.label :real_series_name, "Series"
= f.text_field :real_series_name = f.text_field :real_series_name, autofocus: !@alt_style.real_series_name?
= f.label :thumbnail_url, "Thumbnail" = f.label :thumbnail_url, "Thumbnail"
.thumbnail-field .thumbnail-field
- if @alt_style.thumbnail_url? - if @alt_style.thumbnail_url?
@ -36,5 +36,5 @@
Then: Go to unlabeled style Then: Go to unlabeled style
- content_for :stylesheets do - content_for :stylesheets do
= stylesheet_link_tag "application/breadcrumbs" = stylesheet_link_tag "application/breadcrumbs", "application/support-form"
= page_stylesheet_link_tag "alt_styles/edit" = page_stylesheet_link_tag "alt_styles/edit"

View file

@ -46,6 +46,8 @@
= 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

@ -0,0 +1,59 @@
- 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.
= form_with model: @item, class: "support-form" do |f|
- if @item.errors.any?
%p
Could not save:
%ul.errors
- @item.errors.each do |error|
%li= error.full_message
%fieldset
= f.label :name
= f.text_field :name
= f.label :thumbnail_url, "Thumbnail"
.thumbnail-field
- if @item.thumbnail_url?
= image_tag @item.thumbnail_url
= f.url_field :thumbnail_url
= f.label :description
= f.text_field :description
.field-name Item kind
.radio-field
%label{title: "NC items generally have a rarity value of 500.\nPaintbrush items generally contain a special message in the description."}
= f.radio_button :is_manually_nc, false
Automatic: Based on rarity and description
%label{title: "Use this when Neopets releases an NC item, but labels the rarity as something other than 500, usually by mistake."}
= f.radio_button :is_manually_nc, true
Manually NC: From the NC Mall, but not r500
.field-name Modeling status
.radio-field
%label{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."}
= f.radio_button :modeling_status_hint, ""
Automatic: Fits 2+ species &rarr; Should fit all
%label{title: "Use this when e.g. there simply is no Acara version of the item."}
= f.radio_button :modeling_status_hint, "done"
Done: Neopets.com is missing some models
%label{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!"}
= f.radio_button :modeling_status_hint, "glitchy"
Glitchy: Neopets.com has <em>too many</em> models
.field-name Body fit
.radio-field
%label{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."}
= f.radio_button :explicitly_body_specific, false
Automatic: Some zones fit all species
%label{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."}
= f.radio_button :explicitly_body_specific, true
Body-specific: Fits all species differently
.actions
= f.submit "Save changes"
- content_for :stylesheets do
= page_stylesheet_link_tag "application/support-form"

View file

@ -6,18 +6,19 @@
- if show_announcement? - if show_announcement?
%section.announcement %section.announcement
= image_tag "about/announcement.png", width: 70, height: 70, = image_tag "/images/error-grundo.png", width: 70, height: 70,
srcset: {"about/announcement@2x.png": "2x"} srcset: {"/images/error-grundo.png": "2x"}
.content .content
%p %p
%strong %strong
🎃 Oops, sorry for the bugs recently!
= link_to "New pet styles are out today!", alt_styles_path For the first time in One Million Years, we made some changes to our
If you've seen one we don't have yet, please model it by entering the modeling code—and it looks like we goofed it, and started gradually
pet's name in the box below. Thank you!! losing some data!
%p %p
By the way, we had a bug where modeling new styles wasn't working for a We've restored a backup from before we made these changes, so most
little while. Fixed now! 🤞 everything is back in order! None of your personal data was affected.
Sorry for the disruption, and hope everyone is doing okay! 💜
#outfit-forms #outfit-forms
#pet-preview #pet-preview

View file

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

View file

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

View file

@ -103,6 +103,10 @@ 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,6 +122,10 @@ 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,6 +62,10 @@ 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

@ -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 Alt Style of the %{alt_style_name}" alt_style_html: "a new NC Style of the %{alt_style_name}"
contribution: contribution:
description_html: "%{user_link} showed us %{contributed_description}" description_html: "%{user_link} showed us %{contributed_description}"

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] do resources :items, only: [:index, :show, :edit, :update] 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/}

View file

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

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

@ -0,0 +1,16 @@
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_10_08_004715) do ActiveRecord::Schema[7.2].define(version: 2024_11_19_214543) 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
@ -139,6 +139,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_08_004715) do
t.integer "dyeworks_base_item_id" t.integer "dyeworks_base_item_id"
t.string "cached_occupied_zone_ids", default: "" t.string "cached_occupied_zone_ids", default: ""
t.text "cached_compatible_body_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"
@ -196,8 +197,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_08_004715) 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", limit: 3, null: false t.integer "parent_id", null: false
t.integer "swf_asset_id", limit: 3, null: false t.integer "swf_asset_id", 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
@ -211,7 +212,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_08_004715) 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", limit: 3, null: false t.integer "pet_type_id", 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"
@ -225,8 +226,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_08_004715) do
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", limit: 1, null: false t.integer "color_id", null: false
t.integer "species_id", limit: 1, null: false t.integer "species_id", 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
@ -240,7 +241,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_08_004715) 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", limit: 3, null: false t.integer "pet_type_id", 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
@ -253,7 +254,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_08_004715) 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", limit: 1, null: false t.integer "zone_id", 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,13 +442,21 @@
mode: "755" mode: "755"
state: directory state: directory
- name: Create 10min cron job to run `rails nc_mall:sync` - name: Remove 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:

12
lib/tasks/items.rake Normal file
View file

@ -0,0 +1,12 @@
namespace :items do
desc "Update cached fields for all items (useful if logic changes)"
task :update_cached_fields => :environment 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}"
Item.transaction do
items.each(&:update_cached_fields)
end
end
end
end

View file

@ -0,0 +1,31 @@
module Neologin
def self.cookie
raise "must run neopets:import:neologin first" if @cookie.nil?
@cookie
end
def self.cookie?
@cookie.present?
end
def self.cookie=(new_cookie)
@cookie = new_cookie
end
end
namespace :neopets do
task :import => [
"neopets:import:neologin",
"neopets:import:nc_mall",
"neopets:import:rainbow_pool",
"neopets:import:styling_studio",
]
namespace :import do
task :neologin do
unless Neologin.cookie?
Neologin.cookie = STDIN.getpass("Neologin cookie: ")
end
end
end
end

View file

@ -1,9 +1,11 @@
namespace :nc_mall do namespace "neopets:import" do
desc "Sync our NCMallRecord table with the live NC Mall" desc "Sync our NCMallRecord table with the live NC Mall"
task :sync => :environment do task :nc_mall => :environment do
# Log to STDOUT. # Log to STDOUT.
Rails.logger = Logger.new(STDOUT) Rails.logger = Logger.new(STDOUT)
puts "Importing from NC Mall…"
# First, load all records of what's being sold in the live NC Mall. We load # First, load all records of what's being sold in the live NC Mall. We load
# the homepage and all pages linked from the main document, and extract the # the homepage and all pages linked from the main document, and extract the
# items from each. (We also de-duplicate the items, which is important # items from each. (We also de-duplicate the items, which is important

View file

@ -1,10 +1,10 @@
require "addressable/template" require "addressable/template"
require "async/http/internet/instance" require "async/http/internet/instance"
namespace :rainbow_pool do namespace "neopets:import" do
desc "Import all basic image hashes from the Rainbow Pool, onto PetTypes" desc "Import all basic image hashes from the Rainbow Pool, onto PetTypes"
task :import => :environment do task :rainbow_pool => ["neopets:import:neologin", :environment] do
neologin = STDIN.getpass("Neologin cookie: ") puts "Importing from Rainbow Pool…"
all_pet_types = PetType.all.to_a all_pet_types = PetType.all.to_a
all_pet_types_by_species_id_and_color_id = all_pet_types. all_pet_types_by_species_id_and_color_id = all_pet_types.
@ -16,7 +16,7 @@ namespace :rainbow_pool do
Species.order(:name).each do |species| Species.order(:name).each do |species|
begin begin
hashes_by_color_name = RainbowPool.load_hashes_for_species( hashes_by_color_name = RainbowPool.load_hashes_for_species(
species.id, neologin) species.id, Neologin.cookie)
rescue => error rescue => error
puts "Failed to load #{species.name} page, skipping: #{error.message}" puts "Failed to load #{species.name} page, skipping: #{error.message}"
next next

View file

@ -0,0 +1,87 @@
namespace "neopets:import" do
desc "Import alt style info from the NC Styling Studio"
task :styling_studio => ["neopets:import:neologin", :environment] do
puts "Importing from Styling Studio…"
all_species = Species.order(:name).to_a
# Load 10 species pages from the NC Mall at a time.
barrier = Async::Barrier.new
semaphore = Async::Semaphore.new(10, parent: barrier)
styles_by_species_id = {}
Sync do
num_loaded = 0
num_total = all_species.size
print "0/#{num_total} species loaded"
all_species.each do |species|
semaphore.async {
begin
styles_by_species_id[species.id] = Neopets::NCMall.load_styles(
species_id: species.id,
neologin: Neologin.cookie,
)
rescue => error
puts "\n⚠️ Error loading for #{species.human_name}, skipping: #{error.message}"
end
num_loaded += 1
print "\r#{num_loaded}/#{num_total} species loaded"
}
end
# Wait until all tasks are done.
barrier.wait
ensure
barrier.stop # If something goes wrong, clean up all tasks.
end
print "\n"
style_ids = styles_by_species_id.values.flatten(1).map { |s| s[:oii] }
style_records_by_id =
AltStyle.where(id: style_ids).to_h { |as| [as.id, as] }
all_species.each do |species|
styles = styles_by_species_id[species.id]
next if styles.nil?
counts = {changed: 0, unchanged: 0, skipped: 0}
styles.each do |style|
record = style_records_by_id[style[:oii]]
label = "#{style[:name]} (#{style[:oii]})"
if record.nil?
puts "⚠️ [#{label}]: Not modeled yet, skipping"
counts[:skipped] += 1
next
end
if !record.real_thumbnail_url?
record.thumbnail_url = style[:image]
puts "✅ [#{label}]: Thumbnail URL is now #{style[:image].inspect}"
elsif record.thumbnail_url != style[:image]
puts "⚠️ [#{label}: Thumbnail URL may have changed, handle manually? " +
"#{record.thumbnail_url.inspect} -> #{style[:image].inspect}"
end
new_series_name = style[:name].match(/\A\S+/)[0] # first word
if !record.real_series_name?
record.series_name = new_series_name
puts "✅ [#{label}]: Series name is now #{new_series_name.inspect}"
elsif record.series_name != new_series_name
puts "⚠️ [#{label}: Series name may have changed, handle manually? " +
"#{record.series_name.inspect} -> #{new_series_name.inspect}"
end
if record.changed?
counts[:changed] += 1
else
counts[:unchanged] += 1
end
record.save!
end
puts "#{species.human_name}: #{counts[:changed]} changed, " +
"#{counts[:unchanged]} unchanged, #{counts[:skipped]} skipped"
end
end
end

View file

@ -1,9 +1,28 @@
blue:
id: 8
name: blue
basic: true
green:
id: 34
name: green
basic: true
maraquan:
id: 44
name: maraquan
standard: false
purple: purple:
id: 57 id: 57
name: purple name: purple
red:
id: 61
name: red
basic: true
robot: robot:
id: 62 id: 62
name: robot name: robot
striped: striped:
id: 77 id: 77
name: striped name: striped
swamp_gas:
id: 93
name: "swamp gas"

30
spec/fixtures/items.yml vendored Normal file
View file

@ -0,0 +1,30 @@
straw_hat:
id: 58
name: Straw Hat
description: "This straw hat will keep the sun out of your pets eyes in
bright sunlight."
thumbnail_url: https://images.neopets.com/items/straw-hat.gif
type: Clothes
category: Clothes
rarity: Very Rare
rarity_index: 90
price: 376
weight_lbs: 1
zones_restrict: "0000000000000000000000000001000000001010000000000000"
species_support_ids: "35"
created_at: "2011-03-28T14:33:36-07:00"
birthday_bg:
id: 89876
name: Birthday Bash Background
description: This place is all set for a brilliant birthday bash!
thumbnail_url: https://images.neopets.com/items/9a4gd6g6c0.gif
type: none
category: None
rarity: Special
rarity_index: 101
price: 0
weight_lbs: 1
zones_restrict: "0000000000000000000000000000000000000000000000000000"
species_support_ids: ""
created_at: "2024-11-15T18:15:22-08:00"

19
spec/fixtures/pet_types.yml vendored Normal file
View file

@ -0,0 +1,19 @@
blue_acara:
color_id: 8
species_id: 1
body_id: 123
newcolor_acara:
color_id: 123
species_id: 1
body_id: 123
blue_newspecies:
color_id: 8
species_id: 456
body_id: 123
newcolor_newspecies:
color_id: 123
species_id: 456
body_id: 123

View file

@ -1,3 +1,6 @@
acara:
id: 1
name: acara
blumaroo: blumaroo:
id: 3 id: 3
name: blumaroo name: blumaroo
@ -7,3 +10,9 @@ chia:
jetsam: jetsam:
id: 20 id: 20
name: jetsam name: jetsam
mynci:
id: 35
name: mynci
vandagyre:
id: 55
name: vandagyre

38
spec/models/color_spec.rb Normal file
View file

@ -0,0 +1,38 @@
require_relative '../rails_helper'
RSpec.describe Color do
fixtures :colors
describe '#to_param' do
it("uses name when possible") do
expect(colors(:blue).to_param).to eq "Blue"
end
it("uses spaces for multi-word names") do
expect(colors(:swamp_gas).to_param).to eq "Swamp Gas"
end
it("uses IDs for new colors") do
expect(Color.new(id: 12345).to_param).to eq "12345"
end
end
describe ".param_to_id" do
it("looks up by name") do
expect(Color.param_to_id("blue")).to eq colors(:blue).id
end
it("is case-insensitive for name") do
expect(Color.param_to_id("bLUe")).to eq colors(:blue).id
end
it("returns ID when the param is just a number, even if it doesn't exist") do
expect(Color.param_to_id("123456")).to eq 123456
end
it("raises RecordNotFound if no name matches") do
expect { Color.param_to_id("nonexistant") }.
to raise_error ActiveRecord::RecordNotFound
end
end
end

276
spec/models/item_spec.rb Normal file
View file

@ -0,0 +1,276 @@
require_relative '../rails_helper'
RSpec.describe Item do
fixtures :items, :colors, :species, :zones
context "modeling status:" do
# Rather than using fixtures of real-world data, we create very specific
# pet types, to be able to create small encapsulated test cases where there
# are only a few bodies.
#
# We create some basic color pet types, and some Maraquan pet types—and,
# just like irl, the Maraquan Mynci has the same body as the basic Mynci.
#
# These pet types default to an early creation date of 2005, except the
# Vandagyre, which was released in 2014.
before do
PetType.destroy_all # Make sure no leftovers from e.g. PetType's spec!
build_pt(colors(:blue), species(:acara), body_id: 1).save!
build_pt(colors(:red), species(:acara), body_id: 1).save!
build_pt(colors(:blue), species(:blumaroo), body_id: 2).save!
build_pt(colors(:green), species(:chia), body_id: 3).save!
build_pt(colors(:red), species(:mynci), body_id: 4).save!
build_pt(colors(:blue), species(:vandagyre), body_id: 5).tap do |pt|
pt.created_at = Date.new(2014, 11, 14)
pt.save!
end
build_pt(colors(:maraquan), species(:acara), body_id: 11).save!
build_pt(colors(:maraquan), species(:blumaroo), body_id: 12).save!
build_pt(colors(:maraquan), species(:chia), body_id: 13).save!
build_pt(colors(:maraquan), species(:mynci), body_id: 4).save!
end
def build_pt(color, species, body_id:)
PetType.new(color:, species:, body_id:, created_at: Time.new(2005))
end
def build_item_asset(zone, body_id:)
@remote_id = (@remote_id || 0) + 1
url = "https://images.neopets.example/#{@remote_id}.swf"
SwfAsset.new(type: "object", remote_id: @remote_id, url:,
zones_restrict: "", zone:, body_id:)
end
shared_examples "a fully-modeled item" do
it("is considered fully modeled") { should be_predicted_fully_modeled }
it("predicts no more compatible bodies") do
expect(item.predicted_missing_body_ids).to be_empty
end
it("appears in Item.is_modeled") do
expect(Item.is_modeled.find_by_id(item.id)).to be_present
end
it("does not appear in Item.is_not_modeled") do
expect(Item.is_not_modeled.find_by_id(item.id)).to be_nil
end
end
shared_examples "a not-fully-modeled item" do
it("is not fully modeled") { should_not be_predicted_fully_modeled }
it("does not appear in Item.is_modeled") do
expect(Item.is_modeled.find_by_id(item.id)).to be_nil
end
it("appears in Item.is_not_modeled") do
expect(Item.is_not_modeled.find_by_id(item.id)).to be_present
end
end
describe "an item without any modeling data" do
subject(:item) { items(:birthday_bg) }
it_behaves_like "a not-fully-modeled item"
it("has no compatible body IDs") do
expect(item.compatible_body_ids).to be_empty
end
it("predicts all standard bodies are compatible") do
expect(item.predicted_missing_body_ids).to contain_exactly(
1, 2, 3, 4, 5)
end
end
describe "an item with one species modeled" do
subject(:item) { items(:birthday_bg) }
before do
item.swf_assets << build_item_asset(zones(:wings), body_id: 1)
end
it_behaves_like "a fully-modeled item"
it("has one compatible body ID") do
expect(item.compatible_body_ids).to contain_exactly(1)
end
end
describe "an item with two species modeled" do
subject(:item) { items(:birthday_bg) }
before do
item.swf_assets << build_item_asset(zones(:wings), body_id: 1)
item.swf_assets << build_item_asset(zones(:wings), body_id: 2)
end
it_behaves_like "a not-fully-modeled item"
it("has two compatible body IDs") do
expect(item.compatible_body_ids).to contain_exactly(1, 2)
end
it("predicts remaining standard bodies are compatible") do
expect(item.predicted_missing_body_ids).to contain_exactly(3, 4, 5)
end
end
describe "an item with all standard species modeled" do
subject(:item) { items(:birthday_bg) }
before do
item.swf_assets << build_item_asset(zones(:wings), body_id: 1)
item.swf_assets << build_item_asset(zones(:wings), body_id: 2)
item.swf_assets << build_item_asset(zones(:wings), body_id: 3)
item.swf_assets << build_item_asset(zones(:wings), body_id: 4)
item.swf_assets << build_item_asset(zones(:wings), body_id: 5)
end
it_behaves_like "a fully-modeled item"
it("is compatible with all standard body IDs") do
expect(item.compatible_body_ids).to contain_exactly(1, 2, 3, 4, 5)
end
end
describe "an item that fits all pets the same" do
subject(:item) { items(:birthday_bg) }
before do
item.swf_assets << build_item_asset(zones(:background), body_id: 0)
end
it_behaves_like "a fully-modeled item"
it("is compatible with all bodies (body ID = 0)") do
expect(item.compatible_body_ids).to contain_exactly(0)
end
end
describe "an item with one Maraquan pet modeled" do
subject(:item) { items(:birthday_bg) }
before do
item.swf_assets << build_item_asset(zones(:wings), body_id: 11)
end
it_behaves_like "a fully-modeled item"
it("has one compatible body ID") do
expect(item.compatible_body_ids).to contain_exactly(11)
end
end
describe "an item with two Maraquan pets modeled" do
subject(:item) { items(:birthday_bg) }
before do
item.swf_assets << build_item_asset(zones(:wings), body_id: 11)
item.swf_assets << build_item_asset(zones(:wings), body_id: 12)
end
it_behaves_like "a not-fully-modeled item"
it("has two compatible body IDs") do
expect(item.compatible_body_ids).to contain_exactly(11, 12)
end
it("predicts remaining Maraquan body IDs are compatible") do
expect(item.predicted_missing_body_ids).to contain_exactly(13, 4)
end
end
describe "an item with all Maraquan species modeled" do
subject(:item) { items(:birthday_bg) }
before do
item.swf_assets << build_item_asset(zones(:wings), body_id: 11)
item.swf_assets << build_item_asset(zones(:wings), body_id: 12)
item.swf_assets << build_item_asset(zones(:wings), body_id: 13)
item.swf_assets << build_item_asset(zones(:wings), body_id: 4)
end
it_behaves_like "a fully-modeled item"
it("is compatible with all Maraquan body IDs") do
expect(item.compatible_body_ids).to contain_exactly(11, 12, 13, 4)
end
end
describe "a pre-Vandagyre item without any modeling data" do
subject(:item) { items(:straw_hat) }
it_behaves_like "a not-fully-modeled item"
it("has no compatible body IDs") do
expect(item.compatible_body_ids).to be_empty
end
it("predicts all standard bodies except Vandagyre are compatible") do
expect(item.predicted_missing_body_ids).to contain_exactly(1, 2, 3, 4)
end
end
# Skipping "pre-Vanda with one species modeled", because it's identical.
describe "a pre-Vandagyre item with two species modeled" do
subject(:item) { items(:straw_hat) }
before do
item.swf_assets << build_item_asset(zones(:wings), body_id: 1)
item.swf_assets << build_item_asset(zones(:wings), body_id: 2)
end
it_behaves_like "a not-fully-modeled item"
it("has two compatible body IDs") do
expect(item.compatible_body_ids).to contain_exactly(1, 2)
end
it("predicts remaining standard bodies (sans Vandagyre) are compatible") do
expect(item.predicted_missing_body_ids).to contain_exactly(3, 4)
end
end
describe "a pre-Vandagyre item with all other standard species modeled" do
subject(:item) { items(:straw_hat) }
before do
item.swf_assets << build_item_asset(zones(:wings), body_id: 1)
item.swf_assets << build_item_asset(zones(:wings), body_id: 2)
item.swf_assets << build_item_asset(zones(:wings), body_id: 3)
item.swf_assets << build_item_asset(zones(:wings), body_id: 4)
end
it_behaves_like "a fully-modeled item"
it("is compatible with all non-Vandagyre standard body IDs") do
expect(item.compatible_body_ids).to contain_exactly(1, 2, 3, 4)
end
end
describe "an item without any modeling data, but hinted as done" do
subject(:item) { items(:birthday_bg) }
before { item.update!(modeling_status_hint: :done) }
it_behaves_like "a fully-modeled item"
it("has no compatible body IDs") do
expect(item.compatible_body_ids).to be_empty
end
end
describe "an item with two species modeled, but hinted as done" do
subject(:item) { items(:birthday_bg) }
before do
item.swf_assets << build_item_asset(zones(:wings), body_id: 1)
item.swf_assets << build_item_asset(zones(:wings), body_id: 2)
item.update!(modeling_status_hint: :done)
end
it_behaves_like "a fully-modeled item"
it("has two compatible body IDs") do
expect(item.compatible_body_ids).to contain_exactly(1, 2)
end
end
describe "an item with two species modeled, but hinted as glitchy" do
subject(:item) { items(:birthday_bg) }
before do
item.swf_assets << build_item_asset(zones(:wings), body_id: 1)
item.swf_assets << build_item_asset(zones(:wings), body_id: 2)
item.update!(modeling_status_hint: :glitchy)
end
it_behaves_like "a fully-modeled item"
it("has two compatible body IDs") do
expect(item.compatible_body_ids).to contain_exactly(1, 2)
end
end
end
end

View file

@ -1,4 +1,4 @@
require 'rails_helper' require_relative '../rails_helper'
require_relative '../support/mocks/custom_pets' require_relative '../support/mocks/custom_pets'
require_relative '../support/matchers/a_record_matching' require_relative '../support/matchers/a_record_matching'
@ -32,23 +32,57 @@ RSpec.describe Pet, type: :model do
it("is saved when saving the pet") { pet.save!; should be_persisted } it("is saved when saving the pet") { pet.save!; should be_persisted }
describe "its biology assets" do describe "its biology assets" do
# TODO: I wish biology assets were set up before saving.
# Once we change this, we can un-mark some tests as pending.
before { pet.save! }
subject(:biology_assets) { pet_state.swf_assets } subject(:biology_assets) { pet_state.swf_assets }
let(:asset_ids) { biology_assets.map(&:remote_id) } let(:asset_ids) { biology_assets.map(&:remote_id) }
they("are all new") do they("are all new") { should all be_new_record }
pending("Currently, pets must be saved before assets are assigned.") they("match the expected IDs (before saving)") do
should all be_new_record expect(asset_ids).to contain_exactly(10083, 11613, 14187, 14189)
end end
they("match the expected IDs") do they("match the expected IDs (after saving)") do
pet.save! # TODO: Remove this test once the above passes.
expect(asset_ids).to contain_exactly(10083, 11613, 14187, 14189) expect(asset_ids).to contain_exactly(10083, 11613, 14187, 14189)
end end
they("are saved when saving the pet") { pet.save!; should all be_persisted } they("are saved when saving the pet") { pet.save!; should all be_persisted }
they("have the expected asset metadata") do they("have the expected asset metadata (before saving)") do
expect(pet_state.swf_assets).to contain_exactly( should contain_exactly(
a_record_matching(
type: "biology",
remote_id: 10083,
zone_id: 37,
url: "https://images.neopets.com/cp/bio/swf/000/000/010/10083_8a1111a13f.swf",
manifest_url: "https://images.neopets.com/cp/bio/data/000/000/010/10083_8a1111a13f/manifest.json",
zones_restrict: "0000000000000000000000000000000000000000000000000000",
),
a_record_matching(
type: "biology",
remote_id: 11613,
zone_id: 15,
url: "https://images.neopets.com/cp/bio/swf/000/000/011/11613_f7d8d377ab.swf",
manifest_url: "https://images.neopets.com/cp/bio/data/000/000/011/11613_f7d8d377ab/manifest.json",
zones_restrict: "0000000000000000000000000000000000000000000000000000",
),
a_record_matching(
type: "biology",
remote_id: 14187,
zone_id: 34,
url: "https://images.neopets.com/cp/bio/swf/000/000/014/14187_0e65c2082f.swf",
manifest_url: "https://images.neopets.com/cp/bio/data/000/000/014/14187_0e65c2082f/manifest.json",
zones_restrict: "0000000000000000000000000000000000000000000000000000",
),
a_record_matching(
type: "biology",
remote_id: 14189,
zone_id: 33,
url: "https://images.neopets.com/cp/bio/swf/000/000/014/14189_102e4991e9.swf",
manifest_url: "https://images.neopets.com/cp/bio/data/000/000/014/14189_102e4991e9/manifest.json",
zones_restrict: "0000000000000000000000000000000000000000000000000000",
)
)
end
they("have the expected asset metadata (after saving)") do
pet.save! # TODO: Remove this test once the above passes.
should contain_exactly(
a_record_matching( a_record_matching(
type: "biology", type: "biology",
remote_id: 10083, remote_id: 10083,
@ -96,7 +130,7 @@ RSpec.describe Pet, type: :model do
it("already exists") { should be_persisted } it("already exists") { should be_persisted }
it("is the same as before") { should eq pet.pet_type } it("is the same as before") { should eq pet.pet_type }
it "is not changed when saving the pet" do it "is not changed when saving the pet" do
expect { new_pet.save! }.not_to change { pet_type.attributes } new_pet.save!; expect(pet_type.previous_changes).to be_empty
end end
end end
@ -106,7 +140,7 @@ RSpec.describe Pet, type: :model do
it("already exists") { should be_persisted } it("already exists") { should be_persisted }
it("is the same as before") { should eq pet.pet_state } it("is the same as before") { should eq pet.pet_state }
it "is not changed when saving the pet" do it "is not changed when saving the pet" do
expect { new_pet.save! }.not_to change { pet_state.attributes } new_pet.save!; expect(pet_state.previous_changes).to be_empty
end end
end end
@ -116,7 +150,7 @@ RSpec.describe Pet, type: :model do
they("already exist") { should all be_persisted } they("already exist") { should all be_persisted }
they("are the same as before") { should eq pet.pet_state.swf_assets } they("are the same as before") { should eq pet.pet_state.swf_assets }
they("are not changed when saving the pet") do they("are not changed when saving the pet") do
expect { new_pet.save! }.not_to change { biology_assets.map(&:attributes) } new_pet.save!; expect(biology_assets.map(&:previous_changes)).to all be_empty
end end
end end
end end
@ -131,7 +165,7 @@ RSpec.describe Pet, type: :model do
it("already exists") { should be_persisted } it("already exists") { should be_persisted }
it("is the same as before") { should eq pet.pet_type } it("is the same as before") { should eq pet.pet_type }
it "is not changed when saving the pet" do it "is not changed when saving the pet" do
expect { new_pet.save! }.not_to change { pet_type.attributes } new_pet.save!; expect(pet_type.previous_changes).to be_empty
end end
end end
@ -144,23 +178,66 @@ RSpec.describe Pet, type: :model do
it("is saved when saving the pet") { new_pet.save!; should be_persisted } it("is saved when saving the pet") { new_pet.save!; should be_persisted }
describe "its biology assets" do describe "its biology assets" do
# TODO: I wish biology assets were set up before saving.
# Once we change this, we can un-mark some tests as pending.
before { new_pet.save! }
subject(:biology_assets) { pet_state.swf_assets } subject(:biology_assets) { pet_state.swf_assets }
let(:asset_ids) { biology_assets.map(&:remote_id) } let(:asset_ids) { biology_assets.map(&:remote_id) }
let(:persisted_asset_ids) {
biology_assets.select(&:persisted?).map(&:remote_id)
}
let(:new_asset_ids) {
biology_assets.select(&:new_record?).map(&:remote_id)
}
they("are partially new, partially existing") do they("are partially new, partially existing") do
pending("Currently, pets must be saved before assets are assigned.") expect(persisted_asset_ids).to contain_exactly(10083, 11613)
fail # TODO: Write this test once we have the ability to see it pass! expect(new_asset_ids).to contain_exactly(10448, 10451)
end end
they("match the expected IDs") do they("match the expected IDs (before saving)") do
expect(asset_ids).to contain_exactly(10083, 11613, 10448, 10451)
end
they("match the expected IDs (after saving)") do
new_pet.save! # TODO: Remove this test once the above passes.
expect(asset_ids).to contain_exactly(10083, 11613, 10448, 10451) expect(asset_ids).to contain_exactly(10083, 11613, 10448, 10451)
end end
they("are saved when saving the pet") { new_pet.save!; should all be_persisted } they("are saved when saving the pet") { new_pet.save!; should all be_persisted }
they("have the expected asset metadata") do they("have the expected asset metadata (before saving)") do
expect(pet_state.swf_assets).to contain_exactly( should contain_exactly(
a_record_matching(
type: "biology",
remote_id: 10083,
zone_id: 37,
url: "https://images.neopets.com/cp/bio/swf/000/000/010/10083_8a1111a13f.swf",
manifest_url: "https://images.neopets.com/cp/bio/data/000/000/010/10083_8a1111a13f/manifest.json",
zones_restrict: "0000000000000000000000000000000000000000000000000000",
),
a_record_matching(
type: "biology",
remote_id: 11613,
zone_id: 15,
url: "https://images.neopets.com/cp/bio/swf/000/000/011/11613_f7d8d377ab.swf",
manifest_url: "https://images.neopets.com/cp/bio/data/000/000/011/11613_f7d8d377ab/manifest.json",
zones_restrict: "0000000000000000000000000000000000000000000000000000",
),
a_record_matching(
type: "biology",
remote_id: 10448,
zone_id: 34,
url: "https://images.neopets.com/cp/bio/swf/000/000/010/10448_0b238e79e2.swf",
manifest_url: "https://images.neopets.com/cp/bio/data/000/000/010/10448_0b238e79e2/manifest.json",
zones_restrict: "0000000000000000000000000000000000000000000000000000",
),
a_record_matching(
type: "biology",
remote_id: 10451,
zone_id: 33,
url: "https://images.neopets.com/cp/bio/swf/000/000/010/10451_cd4a8a8e47.swf",
manifest_url: "https://images.neopets.com/cp/bio/data/000/000/010/10451_cd4a8a8e47/manifest.json",
zones_restrict: "0000000000000000000000000000000000000000000000000000",
)
)
end
they("have the expected asset metadata (after saving)") do
new_pet.save! # TODO: Remove this test once the above passes.
should contain_exactly(
a_record_matching( a_record_matching(
type: "biology", type: "biology",
remote_id: 10083, remote_id: 10083,
@ -208,18 +285,15 @@ RSpec.describe Pet, type: :model do
it("is a Striped Blumaroo") { expect(pet.pet_type.human_name).to eq "Striped Blumaroo" } it("is a Striped Blumaroo") { expect(pet.pet_type.human_name).to eq "Striped Blumaroo" }
describe "its biology assets" do describe "its biology assets" do
# TODO: I wish biology assets were set up before saving.
# Once we change this, we can un-mark some tests as pending.
before { pet.save! }
subject(:biology_assets) { pet.pet_state.swf_assets } subject(:biology_assets) { pet.pet_state.swf_assets }
let(:asset_ids) { biology_assets.map(&:remote_id) } let(:asset_ids) { biology_assets.map(&:remote_id) }
they("are all new") do they("are all new") { should all be_new_record }
pending("Currently, pets must be saved before assets are assigned.") they("match the expected IDs (before saving)") do
should all be_new_record expect(asset_ids).to contain_exactly(331, 332, 333, 23760, 23411)
end end
they("match the expected IDs") do they("match the expected IDs (after saving)") do
pet.save! # TODO: Remove this test once the above passes.
expect(asset_ids).to contain_exactly(331, 332, 333, 23760, 23411) expect(asset_ids).to contain_exactly(331, 332, 333, 23760, 23411)
end end
they("are saved when saving the pet") { pet.save!; should all be_persisted } they("are saved when saving the pet") { pet.save!; should all be_persisted }
@ -228,13 +302,14 @@ RSpec.describe Pet, type: :model do
describe "its items" do describe "its items" do
subject(:items) { pet.items } subject(:items) { pet.items }
let(:item_ids) { items.map(&:id) } let(:item_ids) { items.map(&:id) }
let(:compatible_body_ids) { items.to_h { |i| [i.id, i.compatible_body_ids] } }
they("are all new") { should all be_new_record } they("are all new") { should all be_new_record }
they("match the expected IDs") do they("match the expected IDs") do
expect(item_ids).to contain_exactly(39552, 53874, 71706) expect(item_ids).to contain_exactly(39552, 53874, 71706)
end end
they("are saved when saving the pet") { pet.save! ; should all be_persisted } they("are saved when saving the pet") { pet.save! ; should all be_persisted }
they("have the expected item metadata") do they("have the expected item metadata (without even saving first)") do
should contain_exactly( should contain_exactly(
a_record_matching( a_record_matching(
id: 39552, id: 39552,
@ -280,26 +355,66 @@ RSpec.describe Pet, type: :model do
), ),
) )
end end
they("should be marked compatible with this pet's body ID") do
pet.save!
expect(compatible_body_ids).to eq(
39552 => [47],
53874 => [47],
71706 => [0],
)
end
end end
context "its item assets" do context "its item assets" do
# TODO: I wish item assets were set up before saving.
# Once we change this, we can un-mark some tests as pending.
before { pet.save! }
let(:assets_by_item) { pet.items.to_h { |item| [item.id, item.swf_assets.to_a] } } let(:assets_by_item) { pet.items.to_h { |item| [item.id, item.swf_assets.to_a] } }
subject(:item_assets) { assets_by_item.values.flatten(1) } subject(:item_assets) { assets_by_item.values.flatten(1) }
let(:asset_ids) { item_assets.map(&:remote_id) } let(:asset_ids) { item_assets.map(&:remote_id) }
they("are all new") do they("are all new") { should all be_new_record }
pending("Currently, pets must be saved before assets are assigned.") pending("match the expected IDs (before saving)") do
should all be_new_record expect(asset_ids).to contain_exactly(16933, 108567, 410722)
end end
they("match the expected IDs") do they("match the expected IDs (after saving)") do
pet.save! # TODO: Remove this test once the above passes.
expect(asset_ids).to contain_exactly(16933, 108567, 410722) expect(asset_ids).to contain_exactly(16933, 108567, 410722)
end end
they("are saved when saving the pet") { pet.save! ; should all be_persisted } they("are saved when saving the pet") { pet.save! ; should all be_persisted }
they("match the expected metadata") do pending("match the expected metadata (before saving)") do
expect(assets_by_item).to match(
39552 => a_collection_containing_exactly(
a_record_matching(
type: "object",
remote_id: 16933,
zone_id: 35,
url: "https://images.neopets.com/cp/items/swf/000/000/016/16933_0833353c4f.swf",
manifest_url: "https://images.neopets.com/cp/items/data/000/000/016/16933_0833353c4f/manifest.json?v=1706",
zones_restrict: "",
)
),
53874 => a_collection_containing_exactly(
a_record_matching(
type: "object",
remote_id: 108567,
zone_id: 23,
url: "https://images.neopets.com/cp/items/swf/000/000/108/108567_ee88141325.swf",
manifest_url: "https://images.neopets.com/cp/items/data/000/000/108/108567_ee88141325/manifest.json?v=1706",
zones_restrict: "",
)
),
71706 => a_collection_containing_exactly(
a_record_matching(
type: "object",
remote_id: 410722,
zone_id: 3,
url: "https://images.neopets.com/cp/items/swf/000/000/410/410722_3bcd2f5e11.swf",
manifest_url: "https://images.neopets.com/cp/items/data/000/000/410/410722_3bcd2f5e11/manifest.json?v=1706",
zones_restrict: "",
)
),
)
end
they("match the expected metadata (after saving)") do
pet.save! # TODO: Remove this test after the above passes.
expect(assets_by_item).to match( expect(assets_by_item).to match(
39552 => a_collection_containing_exactly( 39552 => a_collection_containing_exactly(
a_record_matching( a_record_matching(
@ -345,7 +460,7 @@ RSpec.describe Pet, type: :model do
it("already exists") { should be_persisted } it("already exists") { should be_persisted }
it("is the same as before") { should eq pet.pet_type } it("is the same as before") { should eq pet.pet_type }
it "is not changed when saving the pet" do it "is not changed when saving the pet" do
expect { new_pet.save! }.not_to change { pet_type.attributes } new_pet.save!; expect(pet_type.previous_changes).to be_empty
end end
end end
@ -355,7 +470,7 @@ RSpec.describe Pet, type: :model do
it("already exists") { should be_persisted } it("already exists") { should be_persisted }
it("is the same as before") { should eq pet.pet_state } it("is the same as before") { should eq pet.pet_state }
it "is not changed when saving the pet" do it "is not changed when saving the pet" do
expect { new_pet.save! }.not_to change { pet_state.attributes } new_pet.save!; expect(pet_state.previous_changes).to be_empty
end end
end end
@ -365,7 +480,7 @@ RSpec.describe Pet, type: :model do
they("already exist") { should all be_persisted } they("already exist") { should all be_persisted }
they("are the same as before") { should eq pet.pet_state.swf_assets } they("are the same as before") { should eq pet.pet_state.swf_assets }
they("are not changed when saving the pet") do they("are not changed when saving the pet") do
expect { new_pet.save! }.not_to change { biology_assets.map(&:attributes) } new_pet.save!; expect(biology_assets.map(&:previous_changes)).to all be_empty
end end
end end
@ -375,7 +490,7 @@ RSpec.describe Pet, type: :model do
they("already exist") { should all be_persisted } they("already exist") { should all be_persisted }
they("are the same as before") { should eq pet.items } they("are the same as before") { should eq pet.items }
they("are not changed when saving the pet") do they("are not changed when saving the pet") do
expect { new_pet.save! }.not_to change { items.map(&:attributes) } new_pet.save!; expect(items.map(&:previous_changes)).to all be_empty
end end
end end
@ -385,7 +500,26 @@ RSpec.describe Pet, type: :model do
they("already exist") { should all be_persisted } they("already exist") { should all be_persisted }
they("are the same as before") { should eq pet.items.map(&:swf_assets).flatten(1) } they("are the same as before") { should eq pet.items.map(&:swf_assets).flatten(1) }
they("are not changed when saving the pet") do they("are not changed when saving the pet") do
expect { new_pet.save! }.not_to change { item_assets.map(&:attributes) } new_pet.save!; expect(item_assets.map(&:previous_changes)).to all be_empty
end
end
end
context "when modeled a second time, but as a Blue Acara" do
before { pet.save! }
subject(:new_pet) { Pet.load("matts_bat:acara") }
describe "its items" do
subject(:items) { new_pet.items }
let(:compatible_body_ids) { items.to_h { |i| [i.id, i.compatible_body_ids] } }
they("should be marked compatible with both pets' body IDs") do
new_pet.save!
expect(compatible_body_ids).to eq(
39552 => [47, 93],
53874 => [47, 93],
71706 => [0],
)
end end
end end
end end
@ -416,9 +550,30 @@ RSpec.describe Pet, type: :model do
it("has no series name yet") { expect(alt_style.real_series_name?).to be false } it("has no series name yet") { expect(alt_style.real_series_name?).to be false }
it("has no thumbnail yet") { expect(alt_style.thumbnail_url?).to be false } it("has no thumbnail yet") { expect(alt_style.thumbnail_url?).to be false }
it("is saved when saving the pet") { pet.save!; should be_persisted } it("is saved when saving the pet") { pet.save!; should be_persisted }
end
# TODO: Alt style assets! describe "its assets" do
subject(:assets) { alt_style.swf_assets }
let(:asset_ids) { assets.map(&:remote_id) }
they("are all new") { should all be_new_record }
they("match the expected IDs") do
expect(asset_ids).to contain_exactly(56223)
end
they("are saved when saving the pet") { pet.save!; should all be_persisted }
they("have the expected asset metadata") do
should contain_exactly(
a_record_matching(
type: "biology",
remote_id: 56223,
zone_id: 15,
url: "https://images.neopets.com/cp/bio/swf/000/000/056/56223_dc26edc764.swf",
manifest_url: "https://images.neopets.com/cp/bio/data/000/000/056/56223_dc26edc764/manifest.json",
zones_restrict: "0000000000000000000000000000000000000000000000000000",
)
)
end
end
end
context "when modeled a second time" do context "when modeled a second time" do
before { pet.save! } before { pet.save! }
@ -430,11 +585,29 @@ RSpec.describe Pet, type: :model do
it("already exists") { should be_persisted } it("already exists") { should be_persisted }
it("is the same as before") { should eq pet.alt_style } it("is the same as before") { should eq pet.alt_style }
it "is not changed when saving the pet" do it "is not changed when saving the pet" do
expect { new_pet.save! }.not_to change { alt_style.attributes } new_pet.save!; expect(alt_style.previous_changes).to be_empty
end
describe "its assets" do
subject(:assets) { alt_style.swf_assets }
they("already exist") { should all be_persisted }
they("are the same as before") { should eq pet.alt_style.swf_assets }
they("are not changed when saving the pet") do
new_pet.save!; expect(assets.map(&:previous_changes)).to all be_empty
end end
end end
end end
end end
end end
end end
context "when modeling is disabled" do
before { allow(Rails.configuration).to receive(:modeling_enabled) { false } }
it("raises an error") do
expect { Pet.load("matts_bat") }.to raise_error(Pet::ModelingDisabled)
end
end
end
end end

View file

@ -0,0 +1,41 @@
require_relative '../rails_helper'
RSpec.describe PetType do
fixtures :colors, :species, :pet_types
describe '#to_param' do
it('uses color and species name when possible ("Blue-Acara")') do
expect(pet_types(:blue_acara).to_param).to eq "Blue-Acara"
end
it('uses color ID for new colors (123-Acara)') do
expect(pet_types(:newcolor_acara).to_param).to eq "123-Acara"
end
it('uses species ID for new colors (Blue-456)') do
expect(pet_types(:blue_newspecies).to_param).to eq "Blue-456"
end
it('uses color ID and species ID when both are new (123-456)') do
expect(pet_types(:newcolor_newspecies).to_param).to eq "123-456"
end
end
describe ".find_by_param!" do
it('looks up by species and color name ("Blue-Acara")') do
expect(PetType.find_by_param!("Blue-Acara")).to eq pet_types(:blue_acara)
end
it('looks up by color ID for new colors ("123-Acara")') do
expect(PetType.find_by_param!("123-Acara")).to eq pet_types(:newcolor_acara)
end
it('looks up by species ID for new species ("Blue-456")') do
expect(PetType.find_by_param!("Blue-456")).to eq pet_types(:blue_newspecies)
end
it('looks up by color ID and species ID when both are new ("123-456")') do
expect(PetType.find_by_param!("123-456")).to eq pet_types(:newcolor_newspecies)
end
end
end

View file

@ -0,0 +1,34 @@
require_relative '../rails_helper'
RSpec.describe Species do
fixtures :species
describe '#to_param' do
it("uses name when possible") do
expect(species(:acara).to_param).to eq "Acara"
end
it("uses IDs for new species") do
expect(Species.new(id: 12345).to_param).to eq "12345"
end
end
describe ".param_to_id" do
it("looks up by name") do
expect(Species.param_to_id("acara")).to eq species(:acara).id
end
it("is case-insensitive for name") do
expect(Species.param_to_id("aCaRa")).to eq species(:acara).id
end
it("returns ID when the param is just a number, even if no record exists") do
expect(Species.param_to_id("123456")).to eq 123456
end
it("raises RecordNotFound if no name matches") do
expect { Species.param_to_id("nonexistant") }.
to raise_error ActiveRecord::RecordNotFound
end
end
end

View file

@ -0,0 +1,89 @@
require 'webmock/rspec'
require_relative '../rails_helper'
RSpec.describe Neopets::NCMall, type: :model do
describe ".load_styles" do
def stub_styles_request
stub_request(:post, "https://www.neopets.com/np-templates/ajax/stylingstudio/studio.php").
with(
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-Requested-With": "XMLHttpRequest",
"Cookie": "neologin=STUB_NEOLOGIN"
},
body: "mode=getStyles&species=2&tab=1",
)
end
subject(:styles) do
Neopets::NCMall.load_styles(
species_id: 2,
neologin: "STUB_NEOLOGIN",
)
end
it "loads current NC styles from the NC Mall" do
stub_styles_request.to_return(
body: '{"success":true,"styles":{"87966":{"oii":87966,"name":"Nostalgic Alien Aisha","image":"https:\/\/images.neopets.com\/items\/nostalgic_alien_aisha.gif","limited":false},"87481":{"oii":87481,"name":"Nostalgic Sponge Aisha","image":"https:\/\/images.neopets.com\/items\/nostalgic_sponge_aisha.gif","limited":false},"90031":{"oii":90031,"name":"Celebratory Anniversary Aisha","image":"https:\/\/images.neopets.com\/items\/624dc08bcf.gif","limited":true},"90050":{"oii":90050,"name":"Nostalgic Tyrannian Aisha","image":"https:\/\/images.neopets.com\/items\/b225e06541.gif","limited":true}}}',
)
expect(styles).to contain_exactly(
{
oii: 87481,
name: "Nostalgic Sponge Aisha",
image: "https://images.neopets.com/items/nostalgic_sponge_aisha.gif",
limited: false,
},
{
oii: 87966,
name: "Nostalgic Alien Aisha",
image: "https://images.neopets.com/items/nostalgic_alien_aisha.gif",
limited: false,
},
{
oii: 90031,
name: "Celebratory Anniversary Aisha",
image: "https://images.neopets.com/items/624dc08bcf.gif",
limited: true,
},
{
oii: 90050,
name: "Nostalgic Tyrannian Aisha",
image: "https://images.neopets.com/items/b225e06541.gif",
limited: true,
}
)
end
it "handles the NC Mall's odd API behavior for zero styles" do
stub_styles_request.to_return(
# You'd think styles would be `{}` in this case, but it's `[]`. Huh!
body: '{"success":true,"styles":[]}',
)
expect(styles).to be_empty
end
it "raises an error if the request returns a non-200 status" do
stub_styles_request.to_return(status: 400)
expect { styles }.to raise_error(Neopets::NCMall::ResponseNotOK)
end
it "raises an error if the request returns a non-JSON response" do
stub_styles_request.to_return(
body: "Oops, this request failed for some weird reason!",
)
expect { styles }.to raise_error(Neopets::NCMall::UnexpectedResponseFormat)
end
it "raises an error if the request returns unexpected JSON" do
stub_styles_request.to_return(
body: '{"success": false}',
)
expect { styles }.to raise_error(Neopets::NCMall::UnexpectedResponseFormat)
end
end
end

View file

@ -0,0 +1,194 @@
{
"dti_comment": "This is matts_bat, hand-modified to be a Blue Acara wearing the same items.",
"custom_pet": {
"name": "matts_bat",
"owner": "matchu1993",
"slot": 1.0,
"scale": 0.5,
"muted": true,
"body_id": 93.0,
"species_id": 1.0,
"color_id": 8.0,
"alt_style": false,
"alt_color": 8.0,
"style_closet_id": null,
"biology_by_zone": {
"30": {
"part_id": 32185.0,
"zone_id": 30.0,
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/032/32185_dc8f076ae3.swf",
"manifest": "https://images.neopets.com/cp/bio/data/000/000/032/32185_dc8f076ae3/manifest.json",
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
},
"15": {
"part_id": 2425.0,
"zone_id": 15.0,
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/002/2425_501f596cef.swf",
"manifest": "https://images.neopets.com/cp/bio/data/000/000/002/2425_501f596cef/manifest.json",
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
},
"5": {
"part_id": 2426.0,
"zone_id": 5.0,
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/002/2426_898928db88.swf",
"manifest": "https://images.neopets.com/cp/bio/data/000/000/002/2426_898928db88/manifest.json",
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
},
"38": {
"part_id": 2427.0,
"zone_id": 38.0,
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/002/2427_f12853f18a.swf",
"manifest": "https://images.neopets.com/cp/bio/data/000/000/002/2427_f12853f18a/manifest.json",
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
},
"34": {
"part_id": 19157.0,
"zone_id": 34.0,
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/019/19157_f2e42f30e9.swf",
"manifest": "https://images.neopets.com/cp/bio/data/000/000/019/19157_f2e42f30e9/manifest.json",
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
},
"33": {
"part_id": 18945.0,
"zone_id": 33.0,
"asset_url": "https://images.neopets.com/cp/bio/swf/000/000/018/18945_45623865d6.swf",
"manifest": "https://images.neopets.com/cp/bio/data/000/000/018/18945_45623865d6/manifest.json",
"zones_restrict": "0000000000000000000000000000000000000000000000000000"
}
},
"equipped_by_zone": {
"35": {
"asset_id": 16931.0,
"zone_id": 35.0,
"closet_obj_id": 2549145.0
},
"23": {
"asset_id": 108565.0,
"zone_id": 23.0,
"closet_obj_id": 16955628.0
},
"3": {
"asset_id": 410722.0,
"zone_id": 3.0,
"closet_obj_id": 17147987.0
}
},
"original_biology": [
]
},
"closet_items": {
"2549145": {
"closet_obj_id": 2549145.0,
"obj_info_id": 39552.0,
"applied_to": "matts_bat",
"is_wishlist": false,
"expiration": "N/A"
},
"16955628": {
"closet_obj_id": 16955628.0,
"obj_info_id": 53874.0,
"applied_to": "matts_bat",
"is_wishlist": false,
"expiration": "N/A"
},
"17147987": {
"closet_obj_id": 17147987.0,
"obj_info_id": 71706.0,
"applied_to": "matts_bat",
"is_wishlist": false,
"expiration": "N/A"
}
},
"object_info_registry": {
"39552": {
"obj_info_id": 39552.0,
"assets_by_zone": {
"35": 16931.0
},
"zones_restrict": "0000000000000000000000000000000000000000000000000000",
"is_compatible": true,
"is_paid": true,
"thumbnail_url": "https://images.neopets.com/items/mall_springyeyeglasses.gif",
"name": "Springy Eye Glasses",
"description": "Hey, keep your eyes in your head!",
"category": "Clothes",
"type": "Clothes",
"rarity": "Artifact",
"rarity_index": 500.0,
"price": 0.0,
"weight_lbs": 1.0,
"species_support": [
3.0
],
"converted": true
},
"53874": {
"obj_info_id": 53874.0,
"assets_by_zone": {
"23": 108565.0
},
"zones_restrict": "0000000000000000000000000000000000000000000000000000",
"is_compatible": true,
"is_paid": false,
"thumbnail_url": "https://images.neopets.com/items/clo_404_shirt.gif",
"name": "404 Shirt",
"description": "When Neopets is down, the shirt comes on!",
"category": "Clothes",
"type": "Clothes",
"rarity": "Rare",
"rarity_index": 88.0,
"price": 1701.0,
"weight_lbs": 1.0,
"species_support": [
3.0
],
"converted": true
},
"71706": {
"obj_info_id": 71706.0,
"assets_by_zone": {
"3": 410722.0
},
"zones_restrict": "0000000000000000000000000000000000000000000000000000",
"is_compatible": true,
"is_paid": false,
"thumbnail_url": "https://images.neopets.com/items/gif_roof_onthe_fg.gif",
"name": "On the Roof Background",
"description": "Who is that on the roof?! Could it be...?",
"category": "Special",
"type": "Mystical Surroundings",
"rarity": "Special",
"rarity_index": 101.0,
"price": 0.0,
"weight_lbs": 1.0,
"species_support": [
],
"converted": true
}
},
"object_asset_registry": {
"16931": {
"asset_id": 16931.0,
"zone_id": 35.0,
"asset_url": "https://images.neopets.com/cp/items/swf/000/000/016/16931_aa01126387.swf",
"obj_info_id": 39552.0,
"manifest": "https://images.neopets.com/cp/items/data/000/000/016/16931_aa01126387/manifest.json?v=1706"
},
"108565": {
"asset_id": 108565.0,
"zone_id": 23.0,
"asset_url": "https://images.neopets.com/cp/items/swf/000/000/108/108565_80d238dbaf.swf",
"obj_info_id": 53874.0,
"manifest": "https://images.neopets.com/cp/items/data/000/000/108/108565_80d238dbaf/manifest.json?v=1706"
},
"410722": {
"asset_id": 410722.0,
"zone_id": 3.0,
"asset_url": "https://images.neopets.com/cp/items/swf/000/000/410/410722_3bcd2f5e11.swf",
"obj_info_id": 71706.0,
"manifest": "https://images.neopets.com/cp/items/data/000/000/410/410722_3bcd2f5e11/manifest.json?v=1706"
}
}
}

BIN
vendor/cache/crack-1.0.0.gem vendored Normal file

Binary file not shown.

BIN
vendor/cache/hashdiff-1.1.2.gem vendored Normal file

Binary file not shown.

BIN
vendor/cache/webmock-3.24.0.gem vendored Normal file

Binary file not shown.