Compare commits

...

6 commits

Author SHA1 Message Date
e5bf6d6ba1 Oops, fix remaining references to locale in item search
This is both unnecessary now, but also caused a bug in the new search
stuff where searching by zone would pass an extra `locale` argument to
a filter that doesn't need it!
2024-02-25 15:00:22 -08:00
61a4dcad02 Oops, "fits:blue-acara" search should return non-body-specific items
Idk when this regressed exactly, but probably people didn't super
notice because I don't think it's a very common thing to type directly
into the Infinite Closet search box! (It used to be crucial to the old
wardrobe app.)

But I'm using it in the wardrobe app again now, so, fixed!
2024-02-25 14:47:37 -08:00
a8cbce0864 Start working on new item search in wardrobe-2020!
For now, I'm doing it with a secret feature flag, since I want to be
committing but it isn't all quite working yet!

Search works right, and the appearance data is getting returned, but I
don't have the Apollo Cache integrations yet, which we rely on more
than I remembered!

Also, alt styles will crash it for now!
2024-02-25 14:46:27 -08:00
52e81557c2 Update search filters to consider NP and PB mutually exclusive
`is:np` now means "is not NC and is not PB".

Note that it might be good to make NC and PB explicitly mutually
exclusive too? It would complicate queries though, and not matter in
most cases… the Burlap Usul Bow is the only item that we currently
return for `is:pb is:nc`, which is probably because of a rarity issue?
2024-02-25 12:57:04 -08:00
a3dcaa0f0e Add useItemSearch for wardrobe app, but don't use it yet!
Adding new functionality to the item search JSON endpoint, and adding
an adapter layer to match the GQL format!

Hopefully this will be pretty drop-in-able, we'll see!
2024-02-25 12:06:32 -08:00
ab1fade529 Remove unused React dependency on homepage
Oh right, this was for `modeling.jsx`, which is gone now. Bye!
2024-02-25 10:49:00 -08:00
10 changed files with 340 additions and 95 deletions

View file

@ -30,7 +30,24 @@ class ItemsController < ApplicationController
items: @items.as_json( items: @items.as_json(
methods: [:nc?, :pb?, :owned?, :wanted?], methods: [:nc?, :pb?, :owned?, :wanted?],
), ),
appearances: load_appearances, appearances: load_appearances.as_json(
include: {
swf_assets: {
only: [:id, :remote_id, :body_id],
include: {
zone: {
only: [:id, :depth, :label],
methods: [:is_commonly_used_by_items],
},
restricted_zones: {
only: [:id, :depth, :label],
methods: [:is_commonly_used_by_items],
},
},
methods: [:urls, :known_glitches],
},
}
),
total_pages: @items.total_pages, total_pages: @items.total_pages,
query: @query.to_s, query: @query.to_s,
} }
@ -104,11 +121,12 @@ class ItemsController < ApplicationController
end end
def load_appearances def load_appearances
pet_type_name = params[:with_appearances_for] appearance_params = params[:with_appearances_for]
return {} if pet_type_name.blank? return {} if appearance_params.blank?
pet_type = Item::Search::Query.load_pet_type_by_name(pet_type_name) pet_type = Item::Search::Query.load_pet_type_by_color_and_species(
pet_type.appearances_for(@items.map(&:id)) appearance_params[:color_id], appearance_params[:species_id])
pet_type.appearances_for(@items.map(&:id), swf_asset_includes: [:zone])
end end
def search_error(e) def search_error(e)

View file

@ -1,7 +1,9 @@
import React from "react";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { useDebounce } from "../util"; import { useDebounce, useLocalStorage } from "../util";
import { emptySearchQuery } from "./SearchToolbar"; import { useItemSearch } from "../loaders/items";
import { emptySearchQuery, searchQueryIsEmpty } from "./SearchToolbar";
import { itemAppearanceFragment } from "../components/useOutfitAppearance"; import { itemAppearanceFragment } from "../components/useOutfitAppearance";
import { SEARCH_PER_PAGE } from "./SearchPanel"; import { SEARCH_PER_PAGE } from "./SearchPanel";
@ -41,11 +43,20 @@ export function useSearchResults(
const currentPageIndex = currentPageNumber - 1; const currentPageIndex = currentPageNumber - 1;
const offset = currentPageIndex * SEARCH_PER_PAGE; const offset = currentPageIndex * SEARCH_PER_PAGE;
// Here's the actual GQL query! At the bottom we have more config than usual! // Until the new item search is ready, we can toggle between them! Use
// `setItemSearchQueryMode` in the JS console to choose "gql" or "new".
const [queryMode, setQueryMode] = useLocalStorage(
"DTIItemSearchQueryMode",
"gql",
);
React.useEffect(() => {
window.setItemSearchQueryMode = setQueryMode;
}, [setQueryMode]);
const { const {
loading: loadingGQL, loading: loadingGQL,
error, error: errorGQL,
data, data: dataGQL,
} = useQuery( } = useQuery(
gql` gql`
query SearchPanel( query SearchPanel(
@ -122,6 +133,7 @@ export function useSearchResults(
context: { sendAuth: true }, context: { sendAuth: true },
skip: skip:
skip || skip ||
queryMode !== "gql" ||
(!debouncedQuery.value && (!debouncedQuery.value &&
!debouncedQuery.filterToItemKind && !debouncedQuery.filterToItemKind &&
!debouncedQuery.filterToZoneLabel && !debouncedQuery.filterToZoneLabel &&
@ -134,10 +146,69 @@ export function useSearchResults(
}, },
); );
const loading = debouncedQuery !== query || loadingGQL; const {
const items = data?.itemSearch?.items ?? []; isLoading: loadingQuery,
const numTotalItems = data?.itemSearch?.numTotalItems ?? null; error: errorQuery,
const numTotalPages = Math.ceil(numTotalItems / SEARCH_PER_PAGE); data: dataQuery,
} = useItemSearch(
{
filters: buildSearchFilters(debouncedQuery, outfitState),
withAppearancesFor: { speciesId, colorId, altStyleId },
page: currentPageIndex + 1,
perPage: SEARCH_PER_PAGE,
},
{
enabled:
!skip && queryMode === "new" && !searchQueryIsEmpty(debouncedQuery),
},
);
const loading =
debouncedQuery !== query ||
(queryMode === "gql" ? loadingGQL : loadingQuery);
const error = queryMode === "gql" ? errorGQL : errorQuery;
const items =
(queryMode === "gql" ? dataGQL?.itemSearch?.items : dataQuery?.items) ?? [];
const numTotalPages =
(queryMode === "gql"
? Math.ceil((dataGQL?.itemSearch?.numTotalItems ?? 0) / SEARCH_PER_PAGE)
: dataQuery?.numTotalPages) ?? 0;
return { loading, error, items, numTotalPages }; return { loading, error, items, numTotalPages };
} }
function buildSearchFilters(query, { speciesId, colorId, altStyleId }) {
const filters = [];
if (query.value) {
filters.push({ key: "name", value: query.value });
}
if (query.filterToItemKind === "NC") {
filters.push({ key: "is_nc" });
} else if (query.filterToItemKind === "PB") {
filters.push({ key: "is_pb" });
} else if (query.filterToItemKind === "NP") {
filters.push({ key: "is_np" });
}
if (query.filterToZoneLabel != null) {
filters.push({
key: "occupied_zone_set_name",
value: query.filterToZoneLabel,
});
}
if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
filters.push({ key: "user_closet_hanger_ownership", value: "true" });
} else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
filters.push({ key: "user_closet_hanger_ownership", value: "false" });
}
filters.push({
key: "fits",
value: { speciesId, colorId, altStyleId },
});
return filters;
}

View file

@ -1,5 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { normalizeSwfAssetToLayer } from "./shared-types";
export function useAltStylesForSpecies(speciesId, options = {}) { export function useAltStylesForSpecies(speciesId, options = {}) {
return useQuery({ return useQuery({
...options, ...options,
@ -63,21 +65,3 @@ function normalizeAltStyle(altStyleData) {
}, },
}; };
} }
function normalizeSwfAssetToLayer(swfAssetData) {
return {
id: String(swfAssetData.id),
zone: {
id: String(swfAssetData.zone.id),
depth: swfAssetData.zone.depth,
label: swfAssetData.zone.label,
},
bodyId: swfAssetData.body_id,
knownGlitches: swfAssetData.known_glitches,
svgUrl: swfAssetData.urls.svg,
canvasMovieLibraryUrl: swfAssetData.urls.canvas_library,
imageUrl: swfAssetData.urls.png,
swfUrl: swfAssetData.urls.swf,
};
}

View file

@ -1,5 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { normalizeSwfAssetToLayer, normalizeZone } from "./shared-types";
export function useItemAppearances(id, options = {}) { export function useItemAppearances(id, options = {}) {
return useQuery({ return useQuery({
...options, ...options,
@ -9,7 +11,9 @@ export function useItemAppearances(id, options = {}) {
} }
async function loadItemAppearancesData(id) { async function loadItemAppearancesData(id) {
const res = await fetch(`/items/${encodeURIComponent(id)}/appearances.json`); const res = await fetch(
`/items/${encodeURIComponent(id)}/appearances.json`,
);
if (!res.ok) { if (!res.ok) {
throw new Error( throw new Error(
@ -20,27 +24,121 @@ async function loadItemAppearancesData(id) {
return res.json().then(normalizeItemAppearancesData); return res.json().then(normalizeItemAppearancesData);
} }
export function useItemSearch(searchOptions, queryOptions = {}) {
// Item searches are considered fresh for an hour, unless the search
// includes user-specific filters, in which case React Query will pretty
// aggressively reload it!
const includesUserSpecificFilters = searchOptions.filters.some(
(f) => f.key === "user_closet_hanger_ownership",
);
const staleTime = includesUserSpecificFilters ? 0 : 1000 * 60 * 5;
return useQuery({
...queryOptions,
queryKey: ["itemSearch", buildItemSearchParams(searchOptions)],
queryFn: () => loadItemSearch(searchOptions),
staleTime,
});
}
function buildItemSearchParams({
filters = [],
withAppearancesFor = null,
page = 1,
perPage = 30,
}) {
const params = new URLSearchParams();
for (const [i, { key, value, isPositive }] of filters.entries()) {
params.append(`q[${i}][key]`, key);
if (key === "fits") {
params.append(`q[${i}][value][species_id]`, value.speciesId);
params.append(`q[${i}][value][color_id]`, value.colorId);
if (value.altStyleId != null) {
params.append(`q[${i}][value][alt_style_id]`, value.altStyleId);
}
} else {
params.append(`q[${i}][value]`, value);
}
if (isPositive == false) {
params.append(`q[${i}][is_positive]`, "false");
}
}
if (withAppearancesFor != null) {
const { speciesId, colorId, altStyleId } = withAppearancesFor;
params.append(`with_appearances_for[species_id]`, speciesId);
params.append(`with_appearances_for[color_id]`, colorId);
if (altStyleId != null) {
params.append(`with_appearances_for[alt_style_id]`, altStyleId);
}
}
params.append("page", page);
params.append("per_page", perPage);
return params.toString();
}
async function loadItemSearch(searchOptions) {
const params = buildItemSearchParams(searchOptions);
const res = await fetch(`/items.json?${params}`);
if (!res.ok) {
throw new Error(
`loading item search failed: ${res.status} ${res.statusText}`,
);
}
return res
.json()
.then((data) => normalizeItemSearchData(data, searchOptions));
}
window.loadItemSearch = loadItemSearch;
function normalizeItemAppearancesData(data) { function normalizeItemAppearancesData(data) {
return { return {
name: data.name, name: data.name,
appearances: data.appearances.map((appearance) => ({ appearances: data.appearances.map((appearance) => ({
body: normalizeBody(appearance.body), body: normalizeBody(appearance.body),
swfAssets: appearance.swf_assets.map((asset) => ({ swfAssets: appearance.swf_assets.map(normalizeSwfAssetToLayer),
id: String(asset.id),
knownGlitches: asset.known_glitches,
zone: normalizeZone(asset.zone),
restrictedZones: asset.restricted_zones.map((z) => normalizeZone(z)),
urls: {
swf: asset.urls.swf,
png: asset.urls.png,
manifest: asset.urls.manifest,
},
})),
})), })),
restrictedZones: data.restricted_zones.map((z) => normalizeZone(z)), restrictedZones: data.restricted_zones.map((z) => normalizeZone(z)),
}; };
} }
function normalizeItemSearchData(data, searchOptions) {
return {
id: buildItemSearchParams(searchOptions),
numTotalPages: data.total_pages,
items: data.items.map((item) => ({
id: String(item.id),
name: item.name,
thumbnailUrl: item.thumbnail_url,
isNc: item["nc?"],
isPb: item["pb?"],
currentUserOwnsThis: item["owned?"],
currentUserWantsThis: item["wanted?"],
appearanceOn: normalizeItemSearchAppearance(
data.appearances[item.id],
item,
),
})),
};
}
function normalizeItemSearchAppearance(data, item) {
if (data == null) {
return null;
}
return {
id: `item-${item.id}-body-${data.body.id}`,
layers: data.swf_assets.map(normalizeSwfAssetToLayer),
restrictedZones: data.swf_assets
.map((a) => a.restricted_zones)
.flat()
.map(normalizeZone),
};
}
function normalizeBody(body) { function normalizeBody(body) {
if (String(body.id) === "0") { if (String(body.id) === "0") {
return { id: "0" }; return { id: "0" };
@ -55,7 +153,3 @@ function normalizeBody(body) {
}, },
}; };
} }
function normalizeZone(zone) {
return { id: String(zone.id), label: zone.label, depth: zone.depth };
}

View file

@ -0,0 +1,23 @@
export function normalizeSwfAssetToLayer(data) {
return {
id: String(data.id),
remoteId: String(data.remote_id),
zone: normalizeZone(data.zone),
bodyId: data.body_id,
knownGlitches: data.known_glitches,
svgUrl: data.urls.svg,
canvasMovieLibraryUrl: data.urls.canvas_library,
imageUrl: data.urls.png,
swfUrl: data.urls.swf,
};
}
export function normalizeZone(data) {
return {
id: String(data.id),
depth: data.depth,
label: data.label,
isCommonlyUsedByItems: data.is_commonly_used_by_items,
};
}

View file

@ -29,20 +29,26 @@ class Item < ApplicationRecord
scope :with_closet_hangers, -> { joins(:closet_hangers) } scope :with_closet_hangers, -> { joins(:closet_hangers) }
scope :name_includes, ->(value, locale = I18n.locale) { scope :name_includes, ->(value) {
Item.where("name LIKE ?", "%" + sanitize_sql_like(value) + "%") Item.where("name LIKE ?", "%" + sanitize_sql_like(value) + "%")
} }
scope :name_excludes, ->(value, locale = I18n.locale) { scope :name_excludes, ->(value) {
Item.where("name NOT LIKE ?", "%" + sanitize_sql_like(value) + "%") Item.where("name NOT LIKE ?", "%" + sanitize_sql_like(value) + "%")
} }
scope :is_nc, -> { scope :is_nc, -> {
i = Item.arel_table i = Item.arel_table
where(i[:rarity_index].in(Item::NCRarities).or(i[:is_manually_nc].eq(true))) where(i[:rarity_index].in(Item::NCRarities).or(i[:is_manually_nc].eq(true)))
} }
scope :is_np, -> { scope :is_not_nc, -> {
i = Item.arel_table i = Item.arel_table
where(i[:rarity_index].in(Item::NCRarities).or(i[:is_manually_nc].eq(true)).not) where(i[:rarity_index].in(Item::NCRarities).or(i[:is_manually_nc].eq(true)).not)
} }
scope :is_np, -> {
self.is_not_nc.is_not_pb
}
scope :is_not_np, -> {
self.merge Item.is_nc.or(Item.is_pb)
}
scope :is_pb, -> { scope :is_pb, -> {
where('description LIKE ?', where('description LIKE ?',
'%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%') '%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%')
@ -81,8 +87,7 @@ class Item < ApplicationRecord
where("NOT (#{condition})", *zone_ids) where("NOT (#{condition})", *zone_ids)
} }
scope :fits, ->(body_id) { scope :fits, ->(body_id) {
sa = SwfAsset.arel_table joins(:swf_assets).where(swf_assets: {body_id: [body_id, 0]}).distinct
joins(:swf_assets).where(sa[:body_id].eq(body_id)).distinct
} }
scope :not_fits, ->(body_id) { scope :not_fits, ->(body_id) {
i = Item.arel_table i = Item.arel_table
@ -445,8 +450,23 @@ class Item < ApplicationRecord
@parent_swf_asset_relationships_to_update = rels @parent_swf_asset_relationships_to_update = rels
end end
Appearance = Struct.new(:body, :swf_assets) # NOTE: Adding the JSON serializer makes `as_json` treat this like a model
Appearance::Body = Struct.new(:id, :species) # instead of like a hash, so you can target its children with things like
# the `include` option. This feels clunky though, I wish I had something a
# bit more suited to it!
Appearance = Struct.new(:body, :swf_assets) do
include ActiveModel::Serializers::JSON
def attributes
{body: body, swf_assets: swf_assets}
end
end
Appearance::Body = Struct.new(:id, :species) do
include ActiveModel::Serializers::JSON
def attributes
{id: id, species: species}
end
end
def appearances def appearances
all_swf_assets = swf_assets.to_a all_swf_assets = swf_assets.to_a

View file

@ -18,11 +18,6 @@ class Item
@text || @filters.map(&:to_s).join(' ') @text || @filters.map(&:to_s).join(' ')
end end
def self.locale
(I18n.fallbacks[I18n.locale] &
I18n.locales_with_neopets_language_code).first
end
TEXT_FILTER_EXPR = /([+-]?)(?:(\p{Word}+):)?(?:"([^"]+)"|(\S+))/ TEXT_FILTER_EXPR = /([+-]?)(?:(\p{Word}+):)?(?:"([^"]+)"|(\S+))/
def self.from_text(text, user=nil) def self.from_text(text, user=nil)
filters = [] filters = []
@ -35,8 +30,8 @@ class Item
case key case key
when 'name' when 'name'
filters << (is_positive ? filters << (is_positive ?
Filter.name_includes(value, locale) : Filter.name_includes(value) :
Filter.name_excludes(value, locale)) Filter.name_excludes(value))
when 'occupies' when 'occupies'
filters << (is_positive ? filters << (is_positive ?
Filter.occupies(value) : Filter.occupies(value) :
@ -46,10 +41,11 @@ class Item
Filter.restricts(value) : Filter.restricts(value) :
Filter.not_restricts(value)) Filter.not_restricts(value))
when 'fits' when 'fits'
pet_type = load_pet_type_by_name(value) color_name, species_name = value.split("-")
pet_type = load_pet_type_by_name(color_name, species_name)
filters << (is_positive ? filters << (is_positive ?
Filter.fits(pet_type.body_id, value.downcase) : Filter.fits(pet_type.body_id, color_name, species_name) :
Filter.not_fits(pet_type.body_id, value.downcase)) Filter.not_fits(pet_type.body_id, color_name, species_name))
when 'species' when 'species'
begin begin
species = Species.find_by_name!(value) species = Species.find_by_name!(value)
@ -116,27 +112,30 @@ class Item
case filter_params[:key] case filter_params[:key]
when 'name' when 'name'
filters << (is_positive ? filters << (is_positive ?
Filter.name_includes(value, locale) : Filter.name_includes(value) :
Filter.name_excludes(value, locale)) Filter.name_excludes(value))
when 'is_nc' when 'is_nc'
filters << (is_positive ? Filter.is_nc : Filter.is_not_nc) filters << (is_positive ? Filter.is_nc : Filter.is_not_nc)
when 'is_pb'
filters << (is_positive ? Filter.is_pb : Filter.is_not_pb)
when 'is_np'
filters << (is_positive ? Filter.is_np : Filter.is_not_np)
when 'occupied_zone_set_name' when 'occupied_zone_set_name'
filters << (is_positive ? filters << (is_positive ? Filter.occupies(value) :
Filter.occupies(value, locale) : Filter.not_occupies(value))
Filter.not_occupies(value, locale))
when 'restricted_zone_set_name' when 'restricted_zone_set_name'
filters << (is_positive ? filters << (is_positive ?
Filter.restricts(value, locale) : Filter.restricts(value) :
Filter.not_restricts(value, locale)) Filter.not_restricts(value))
when 'fits_pet_type' when 'fits'
pet_type = PetType.find(value) raise NotImplementedError if value[:alt_style_id].present?
color_name = pet_type.color.name pet_type = load_pet_type_by_color_and_species(
species_name = pet_type.species.name value[:color_id], value[:species_id])
# NOTE: Some color syntaxes are weird, like `fits:"polka dot-aisha"`! color = Color.find value[:color_id]
value = "#{color_name}-#{species_name}" species = Species.find value[:species_id]
filters << (is_positive ? filters << (is_positive ?
Filter.fits(pet_type.body_id, value) : Filter.fits(pet_type.body_id, color.name, species.name) :
Filter.not_fits(pet_type.body_id, value)) Filter.not_fits(pet_type.body_id, color.name, species.name))
when 'user_closet_hanger_ownership' when 'user_closet_hanger_ownership'
case value case value
when 'true' when 'true'
@ -156,9 +155,7 @@ class Item
self.new(filters, user) self.new(filters, user)
end end
def self.load_pet_type_by_name(pet_type_string) def self.load_pet_type_by_name(color_name, species_name)
color_name, species_name = pet_type_string.split("-")
begin begin
PetType.matching_name(color_name, species_name).first! PetType.matching_name(color_name, species_name).first!
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
@ -167,6 +164,18 @@ class Item
raise Item::Search::Error, message raise Item::Search::Error, message
end end
end end
def self.load_pet_type_by_color_and_species(color_id, species_id)
begin
PetType.where(color_id: color_id, species_id: species_id).first!
rescue ActiveRecord::RecordNotFound
color_name = Color.find(color_id).name rescue "Color #{color_id}"
species_name = Species.find(species_id).name rescue "Species #{species_id}"
message = I18n.translate('items.search.errors.not_found.pet_type',
name1: color_name.capitalize, name2: species_name.capitalize)
raise Item::Search::Error, message
end
end
end end
class Error < Exception class Error < Exception
@ -194,12 +203,12 @@ class Item
"#<#{self.class.name} #{@text.inspect}>" "#<#{self.class.name} #{@text.inspect}>"
end end
def self.name_includes(value, locale) def self.name_includes(value)
self.new Item.name_includes(value, locale), "#{q value}" self.new Item.name_includes(value), "#{q value}"
end end
def self.name_excludes(value, locale) def self.name_excludes(value)
self.new Item.name_excludes(value, locale), "-#{q value}" self.new Item.name_excludes(value), "-#{q value}"
end end
def self.occupies(value) def self.occupies(value)
@ -218,11 +227,15 @@ class Item
self.new Item.not_restricts(value), "-restricts:#{q value}" self.new Item.not_restricts(value), "-restricts:#{q value}"
end end
def self.fits(body_id, value) def self.fits(body_id, color_name, species_name)
# NOTE: Some color syntaxes are weird, like `fits:"polka dot-aisha"`!
value = "#{color_name}-#{species_name}".downcase
self.new Item.fits(body_id), "fits:#{q value}" self.new Item.fits(body_id), "fits:#{q value}"
end end
def self.not_fits(body_id, value) def self.not_fits(body_id, color_name, species_name)
# NOTE: Some color syntaxes are weird, like `fits:"polka dot-aisha"`!
value = "#{color_name}-#{species_name}".downcase
self.new Item.not_fits(body_id), "-fits:#{q value}" self.new Item.not_fits(body_id), "-fits:#{q value}"
end end
@ -255,7 +268,7 @@ class Item
end end
def self.is_not_nc def self.is_not_nc
self.new Item.is_np, '-is:nc' self.new Item.is_not_nc, '-is:nc'
end end
def self.is_np def self.is_np
@ -263,7 +276,7 @@ class Item
end end
def self.is_not_np def self.is_not_np
self.new Item.is_nc, '-is:np' self.new Item.is_not_np, '-is:np'
end end
def self.is_pb def self.is_pb

View file

@ -169,16 +169,30 @@ class PetType < ApplicationRecord
}.first }.first
end end
def appearances_for(item_ids) def appearances_for(item_ids, swf_asset_includes: [])
# First, load all the relationships for these items that also fit this # First, load all the relationships for these items that also fit this
# body. # body.
relationships = ParentSwfAssetRelationship.includes(:swf_asset). relationships = ParentSwfAssetRelationship.
includes(swf_asset: swf_asset_includes).
where(parent_type: "Item", parent_id: item_ids). where(parent_type: "Item", parent_id: item_ids).
where(swf_asset: {body_id: [body_id, 0]}) where(swf_asset: {body_id: [body_id, 0]})
pet_type_body = Item::Appearance::Body.new(body_id, species)
all_pets_body = Item::Appearance::Body.new(0, nil)
# Then, convert this into a hash from item ID to SWF assets. # Then, convert this into a hash from item ID to SWF assets.
assets_by_item_id = relationships.group_by(&:parent_id). assets_by_item_id = relationships.group_by(&:parent_id).
transform_values { |rels| rels.map(&:swf_asset) } transform_values { |rels| rels.map(&:swf_asset) }
# Finally, for each item, return an appearance—even if it's empty!
item_ids.to_h do |item_id|
assets = assets_by_item_id.fetch(item_id, [])
fits_all_pets = assets.present? && assets.all? { |a| a.body_id == 0 }
body = fits_all_pets ? all_pets_body : pet_type_body
[item_id, Item::Appearance.new(body, assets)]
end
end end
def self.all_by_ids_or_children(ids, pet_states) def self.all_by_ids_or_children(ids, pet_states)

View file

@ -17,6 +17,14 @@ class Zone < ActiveRecord::Base
@sometimes ? "#{label} sometimes" : label @sometimes ? "#{label} sometimes" : label
end end
def is_commonly_used_by_items
# Zone metadata marks item zones with types 2, 3, and 4. But also, in
# practice, the Biology Effects zone (type 1, ID 4) has been used for a few
# items too. So, that's what we return true for!
# TODO: It'd probably be better to make this a database field?
[2, 3, 4].include?(type_id) || id == 4
end
def self.plainify_label(label) def self.plainify_label(label)
label.delete('\- /').parameterize label.delete('\- /').parameterize
end end

View file

@ -110,4 +110,4 @@
- content_for :javascripts do - content_for :javascripts do
= include_javascript_libraries :jquery20, :jquery_tmpl = include_javascript_libraries :jquery20, :jquery_tmpl
= javascript_include_tag 'ajax_auth', 'lib/react', 'lib/jquery.timeago', 'outfits/new' = javascript_include_tag 'ajax_auth', 'lib/jquery.timeago', 'outfits/new'