Huh, I thought I'd tried some invalid dates and they gave me
*surprising* output instead of raising an error. Well, maybe it can do
both, depending on exactly the nature of the unexpected input?
In any case, I found that a bad month name like "UwU" raised an error.
So, let's catch it if so!
Oh right, if I assume "date in the past means it's for next year", then
that means that, when the date *does pass*, we won't realize it!
e.g. if Owls says "Dyeable Thru July 15", then on July 14 we'll parse
that as July 15, 2024; but on July 16 we'll parse it as July 16, 2025,
and so we'll think it's *still* dyeable. Under this logic, it's
actually impossible for a limited Dyeworks date to *ever* be in the
past, I think!
I think 3 months is a good compromise: it gives Owls plenty of time to
update, but allows for events that could last as long as 9 months into
the future, if I'm doing my math right.
Ahh, I started a tabs-y file (as I default to these days), but copied
code from a spaces-y file, and didn't notice. (My laptop editor isn't
configured to flag this for me, oops!)
Fixed!
There's just starting to be a lot going on, so I pulled them out into
here!
I also considered a like, `Item::DyeworksStatus` class, and then you'd
go like, `item.dyeworks.buyable?`. But idk, I think it's nice that the
current API is simple for callers, and being able to do things like
`items.filter(&:dyeworks_buyable?)` is pretty darn convenient.
This solution lets us keep the increasing number of Dyeworks methods
from polluting the main `item.rb`, while still keeping the API
identical!
Silly mistake, right, we might not have a trade value listed! This is
relevant for the new Dyeworks items that just came out like a few hours
ago, which Owls doesn't have info for yet.
Previously, I added a Dyeworks section that was incorrect: the base
item being available in the NC Mall does *not* mean you can necessarily
dye it with a potion!
In this change, we lean on Owls to tell us more about Dyeworks status,
and only group items in this section that Owls has marked as "Permanent
Dyeworks".
We don't have support for limited-time Dyeworks items yet—I've sent out
a message asking the Owls team for more info on what they do for those
items!
I started writing this up, then sent a preview to a friend, and he was
like "oh cool, but also this is not correct?"
I didn't realize Dyeworks has limited-time support to be *able* to dye
certain items. Hey, glad we're writing this guide for people like me,
then! lol
I wonder if we can lean on Owls for this. It seems like they already
list "Permanent Dyeworks" for some items, I wonder if they say
something special for active limited-edition Dyeworks items!
In this change, instead of *always* inferring the Dyeworks base item
from the item name at runtime, we now have a database field that tracks
it, and auto-populates whenever an item *seems* to need a Dyeworks base
item but doesn't have one yet.
This will enable us to set the base item manually in cases where it
can't be inferred, and load Dyeworks base items for the Item Getting
Guide in one query with `includes(:dyeworks_base_item)`.
This migration does a bit more of the fix-em-up scripting work *in* the
migration itself than I usually do, mainly because there's so much in
this one that I think being extra-explicit is useful. We make sure to
do it gracefully though!
This works for most of the current 1,094 Dyeworks items! But there are
a few exceptions, for cases where the base item name is not quite the
same (e.g. the Dyeworks version is more concise). Maybe we'll add a
database field to override this?
- Dyeworks Baby Blue: Baby Valentine Jumper
- Dyeworks Baby Pink: Baby Valentine Jumper
- Dyeworks Black: Field of Flowers
- Dyeworks Black: Games Master Challenge 2010 Lulu Shirt
- Dyeworks Blue: Field of Flowers
- Dyeworks Blue: Stars and Glitter Facepaint
- Dyeworks Brown: Hanging Winter Candles Garland
- Dyeworks Green: Stars and Glitter Facepaint
- Dyeworks Magenta: Lovely Berry Blush
- Dyeworks Orange & Pink: Winter Lights Effects
- Dyeworks Orange: Games Master Challenge 2010 Lulu Shirt
- Dyeworks Peach: Lovely Berry Blush
- Dyeworks Purple: Baby Valentine Jumper
- Dyeworks Purple: Games Master Challenge 2010 Lulu Shirt
- Dyeworks Purple: Hanging Winter Candles Garland
- Dyeworks Purple: Stars and Glitter Facepaint
- Dyeworks Red & Green: Winter Lights Effects
- Dyeworks Silver: Hanging Winter Candles Garland
- Dyeworks Soft Pink: Lovely Berry Blush
- Dyeworks Yellow & Magenta: Winter Lights Effects
- Dyeworks Yellow: Field of Flowers
This is less likely than the newly-released color case for PB items,
but I figure let's be resilient anyway, especially since it's so easy
to—and also I figure this is less likely to be triggered by an *actual*
new species, and more likely to be triggered by a surprise in an item's
naming conventions.
But yeah, if `Item#pb_species` returns `nil` upstream, it'll be passed
to `Color#example_pet_type`, which will crash trying to read its ID. So
in this change, we update `Color#example_pet_type` to accept a `nil`
value, and fall back to the first Species (Acara) in that case.
This means that, if you e.g. take the Mutant Aisha Collar and delete
the word "Aisha" from the name, then load it in the Item Getting Guide,
you'll see a thumbnail of a Mutant Acara. Good enough!
I noticed in the app that these queries were slowwww! I was able to
track it down to a bad query plan, as we explain in the comment.
I searched online for "mysql query performance filter on one join table
sort by another", and was surprised to find this answer suggest a
subquery, which I've often been told to expect to be slower compared to
joins? But it certainly worked in this case!
https://stackoverflow.com/questions/35679693/mysql-optimize-join-with-filter-and-order-on-different-tables
I think the Rails query cache handled these anyway? But `SwfAsset` has
a `before_save` hook that checks its zone's info, and
`SwfAsset.preload_manifests` saves all the assets, on the assumption
that saving is a no-op when the record didn't change anyway. And it
basically is!
But I figure that, now that I'm realizing hooks exist, simply not
attempting to save unchanged records is probably a better
representation of what we intend to do. So I'm fixing it like that!
Another potential fix would be to preload the zones for these assets,
but I think that confuses the intent too much; the method itself isn't
using the zones, it's just a weird incidental thing that a save hook
happens to use. (Would probably be better to refactor this old save
hook into a different situation altogether, but that's for another
time!)
This is a minor nbd change, I just noticed when playing around in the
console that, unlike most other errors for this model, the `body_id`
being required is _only_ enforced in the database schema, so it isn't
returned with the usual errors. Not a big deal! Just feels like this is
clearer to work with, and more correct to what we *intend*.
This is just a bit of future-proofing! We also add a default thumbnail
URL of the cute "Neopets Circle Background", for cases where the series
name isn't known yet.
Now, for colors like Mutant or Magma where there's no paint brush
image to show, we use a sample pet image instead, to help it have equal
visual weight and clarity as the cases with the paint brushes.
We do some cleverness in here to make sure to always show the relevant
species, if possible!
NC prices, some CSS, and also a new application-level helper that adds
a feature I've long wanted and been working around for Turbo: the
ability to specific that a stylesheet is specific to the current page,
and should be unloaded when removed!
I use this to write `sources.sass` without the usual
`body.items-sources` scoping that we've historically used to control
what pages a stylesheet applies to. (In the long past, this was because
a lot of stylesheets were—and still are–routed through the
`application.sass` stylesheet! But even for more recent standalone page
stylesheets, I've done the scoping, to avoid issues with styles leaking
beyond the page they're meant for when Turbo does a navigation.)
This'll affect the recommended acquisition method by a lot!
NC Mall info like current price isn't surfaced anywhere else in the app
right now. It'd probably be good to add to the item page, and maybe
some other places too!
Currently we only load the homepage, so there's only actually one
wearable item to sync up! But here's the task to do it!
To do this, we also created the backing model NCMallRecord, where we'll
save the current NC Mall state!
TNT requested that we figure out ways to connect the dots between
people's intentions on DTI to their purchases in the NC Mall.
But rather than just slam ad links everywhere, our plan is to design an
actually useful feature about it: the "Item Getting Guide". It'll break
down items by how you can actually get them (NP economy, NC Mall,
retired NC, Dyeworks, etc), and we're planning some cute actions you
can take, like shortcuts for getting them onto trade wishlists or into
your NC Mall cart.
This is just a little demo version of the page, just breaking down
items specified in the URL into NC/NP/PB! Later we'll do more granular
breakdown than this, with more info and actions—and we'll also like,
link to it at all, which isn't the case yet! (The main way we expect
people to get here is by a "Get these items" button we'll add to the
outfit editor, but there might be other paths, too.)
Idk how we got into this state, or if it's environment-dependent or
MySQL-version-dependent or what, but setting up the dev environment on
my macOS machine is complaining that `TEXT` columns can't have default
values.
Well, in that case, let's just have it be a non-nullable field, and add
a note to our code that missing fields *can* cause item saving to fail!
(This was always true, but I'm just extra-noting it because it's
becoming *more* true.)
Simple enough to start! If `shadowbanned: true` gets set on a user,
then we show a 404 instead of the actual list page, *unless* you're
logged in as that user, or coming from a known IP of that user.
This isn't a very strong mechanism! Just something to hopefully
increase the costs of messing around with list spam.
This hasn't been causing issues as far as I know, I just noticed
*months ago* that I forgot to do this, and have had a sticky note about
it on my desk since then lol.
I tested this by temporarily setting the timeout to `0.5`, and watching
it fail!
A further optimization, this lets us use the image hash as the new hash
for the pet type if it would be useful! (whereas before this change,
we'd dip into `fetch_metadata` and just get back `nil`, which was okay
too but a little bit less helpful!)
Ahh, we recently added a step to pet loading that sends a metadata
request to `PetService.getPet`, which is now (in a sense, correctly!)
raising a `PetNotFound` error when we try modeling with a "pet" that
starts with `@` (a trick we use in situations where we can get an image
hash for a modeling situation, but not an irl pet itself).
In this change, we make it no longer a crashing issue if the pet
metadata request fails: it's not a big deal to have a `PetType` have no
image hash or not have it be up-to-date!
In the next change, I'll also add an optimization to skip fetching it
altogether in this case—but I wanted to see this work first, because
the more general resilience is more important imo!
Yay, we got the API endpoint for this! The `linkage` scope is the key.
Rather than pulling back the specific fallback behavior we had wrote
for usernames before, which was slightly different and involved
appending `neopass` in there too (e.g. `matchu-neopass-1234`), I
figured let's just use a lot of the same logic, and just use the
preferred name as the base name. (I figure the `neopass` suffix isn't
that useful anyway, `matchu-1234` kinda looks better tbh! And it's all
fallback stuff that I expect serious users to replace, anyway.)
Oh right, I never did catch this when setting up User-Agent in the app!
(I noticed this because I'm making a new request now, and went to look
how we set it in previous stuff, and was like. Oh. We don't anywhere
right now. Interesting LOL)
Oh right, if you can remove your email, there's a way to fully lock out
your account:
1. Create account via NeoPass, so no password is set.
2. Ensure you have an email saved, then disconnect NeoPass.
3. Remove the email.
4. Now you have no NeoPass, no email, and no password!
In this change, we add a validation that requires an account to always
have at least one login method. This works well for the case described
above, and also helps offer server-side validation to the "can't
disconnect NeoPass until you have an email and password" stuff that
previously was only enforced by disabling the button.
That is, the following procedure could also lock you out before,
whereas now it raises the "Whoops, there was an error disconnecting
your NeoPass from your account, sorry." message:
1. Create account via NeoPass, so no password is set.
2. Ensure you have an email saved, so "Disconnect" button is enabled.
3. Open a new browser tab, and remove the email.
4. In the original browser tab, click "Disconnect".
Ah okay, if you leave the password field blank but don't have one set,
our simple `update` method gets annoyed that you left it blank.
In this change, we simplify the model API by just overriding
`update_with_password` with our own special behavior for the
no-password case.
including validation logic to make sure it's not already connected to
another one!
The `intent` param on the NeoPass form is part of the key! Thanks
OmniAuth for making it easy to pass that data through!
Ahh I see, if you do a no-op update, it still clears the
`previously_new_record?` state, so our NeoPass controller thinks this
account already existed. Instead, let's only do this update if it's an
account that already exists, instead of depending on the no-op-iness!
That is, you're required to add a password *or* an email before
disconnecting your NeoPass, but idk, I think it's rude to demand an
email from someone for the sake of *disconnection*. Email is no longer
required for accounts that already exist!