diff --git a/src/app/ItemPage.js b/src/app/ItemPage.js index f96eed5..69d520e 100644 --- a/src/app/ItemPage.js +++ b/src/app/ItemPage.js @@ -515,19 +515,35 @@ function ItemPageOutfitPreview({ itemId }) { "DTIItemPreviewPreferredSpeciesId", null ); + const [preferredColorId, setPreferredColorId] = useLocalStorage( + "DTIItemPreviewPreferredColorId", + null + ); - const setPetStateFromUserAction = (petState) => { - setPetState(petState); + const setPetStateFromUserAction = (newPetState) => { + setPetState(newPetState); - // When the user _intentionally_ chooses a species, save it in local - // storage for next time. (This won't update when e.g. their preferred - // species isn't available for this item, so we update to the canonical - // species automatically.) - if (petState.speciesId) { - // I have no reason to expect null to come in here, but, since this is - // touching client-persisted data, I want it to be even more guaranteed - // reliable than usual! - setPreferredSpeciesId(petState.speciesId); + // When the user _intentionally_ chooses a species or color, save it in + // local storage for next time. (This won't update when e.g. their + // preferred species or color isn't available for this item, so we update + // to the canonical species or color automatically.) + // + // Re the "ifs", I have no reason to expect null to come in here, but, + // since this is touching client-persisted data, I want it to be even more + // reliable than usual! + if (newPetState.speciesId && newPetState.speciesId !== petState.speciesId) { + setPreferredSpeciesId(newPetState.speciesId); + } + if (newPetState.colorId && newPetState.colorId !== petState.colorId) { + if (colorIsBasic(newPetState.colorId)) { + // When the user chooses a basic color, don't index on it specifically, + // and instead reset to use default colors. + console.log("set to null"); + setPreferredColorId(null); + } else { + console.log("set to color", newPetState.colorId); + setPreferredColorId(newPetState.colorId); + } } }; @@ -544,7 +560,11 @@ function ItemPageOutfitPreview({ itemId }) { // cover standard-color switches works for this preloading too.) const { loading: loadingGQL, error: errorGQL, data } = useQuery( gql` - query ItemPageOutfitPreview($itemId: ID!, $preferredSpeciesId: ID) { + query ItemPageOutfitPreview( + $itemId: ID! + $preferredSpeciesId: ID + $preferredColorId: ID + ) { item(id: $itemId) { id name @@ -552,12 +572,15 @@ function ItemPageOutfitPreview({ itemId }) { id representsAllBodies } - canonicalAppearance(preferredSpeciesId: $preferredSpeciesId) { + canonicalAppearance( + preferredSpeciesId: $preferredSpeciesId + preferredColorId: $preferredColorId + ) { id ...ItemAppearanceForOutfitPreview body { id - canonicalAppearance { + canonicalAppearance(preferredColorId: $preferredColorId) { id species { id @@ -579,7 +602,7 @@ function ItemPageOutfitPreview({ itemId }) { ${petAppearanceFragment} `, { - variables: { itemId, preferredSpeciesId }, + variables: { itemId, preferredSpeciesId, preferredColorId }, onCompleted: (data) => { const canonicalBody = data?.item?.canonicalAppearance?.body; const canonicalPetAppearance = canonicalBody?.canonicalAppearance; @@ -834,9 +857,7 @@ function SpeciesFacesPicker({ // TODO: Could we move this into our `build-cached-data` script, and just do // the query all the time, and have Apollo happen to satisfy it fast? // The semantics of returning our colorful random set could be weird… - const selectedColorIsBasic = ["8", "34", "61", "84"].includes( - selectedColorId - ); + const selectedColorIsBasic = colorIsBasic(selectedColorId); const { loading: loadingGQL, error, data } = useQuery( gql` query SpeciesFacesPicker($selectedColorId: ID!) { @@ -1321,6 +1342,10 @@ function DeferredTooltip({ children, isOpen, ...props }) { ); } +function colorIsBasic(colorId) { + return ["8", "34", "61", "84"].includes(colorId); +} + // HACK: I'm just hardcoding all this, rather than connecting up to the // database and adding a loading state. Tbh I'm not sure it's a good idea // to load this dynamically until we have SSR to make it come in fast! diff --git a/src/server/loaders.js b/src/server/loaders.js index aff55b9..d809aa9 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -977,47 +977,54 @@ const buildPetStatesForPetTypeLoader = (db, loaders) => /** Given a bodyId, loads the canonical PetState to show as an example. */ const buildCanonicalPetStateForBodyLoader = (db, loaders) => - new DataLoader(async (bodyIds) => { - // I don't know how to do this query in bulk, so we'll just do it in - // parallel! - return await Promise.all( - bodyIds.map(async (bodyId) => { - // Randomly-ish choose which gender presentation to prefer, based on - // body ID. This makes the outcome stable, which is nice for caching - // and testing and just generally not being surprised, but sitll - // creates an even distribution. - const gender = bodyId % 2 === 0 ? "masc" : "fem"; + new DataLoader( + async (requests) => { + // I don't know how to do this query in bulk, so we'll just do it in + // parallel! + return await Promise.all( + requests.map(async ({ bodyId, preferredColorId, fallbackColorId }) => { + // Randomly-ish choose which gender presentation to prefer, based on + // body ID. This makes the outcome stable, which is nice for caching + // and testing and just generally not being surprised, but sitll + // creates an even distribution. + const gender = bodyId % 2 === 0 ? "masc" : "fem"; - const [rows, _] = await db.execute( - { - sql: ` + const [rows, _] = await db.execute( + { + sql: ` SELECT pet_states.*, pet_types.* FROM pet_states INNER JOIN pet_types ON pet_types.id = pet_states.pet_type_id WHERE pet_types.body_id = ? ORDER BY - pet_types.color_id = 8 DESC, -- Prefer Blue + pet_types.color_id = ? DESC, -- Prefer preferredColorId + pet_types.color_id = ? DESC, -- Prefer fallbackColorId pet_states.mood_id = 1 DESC, -- Prefer Happy pet_states.female = ? DESC, -- Prefer given gender pet_states.id DESC, -- Prefer recent models (like in the app) pet_states.glitched ASC -- Prefer not glitched (like in the app) LIMIT 1`, - nestTables: true, - }, - [bodyId, gender === "fem"] - ); - const petState = normalizeRow(rows[0].pet_states); - const petType = normalizeRow(rows[0].pet_types); - if (!petState || !petType) { - return null; - } + nestTables: true, + }, + [bodyId, preferredColorId, fallbackColorId, gender === "fem"] + ); + const petState = normalizeRow(rows[0].pet_states); + const petType = normalizeRow(rows[0].pet_types); + if (!petState || !petType) { + return null; + } - loaders.petStateLoader.prime(petState.id, petState); - loaders.petTypeLoader.prime(petType.id, petType); + loaders.petStateLoader.prime(petState.id, petState); + loaders.petTypeLoader.prime(petType.id, petType); - return petState; - }) - ); - }); + return petState; + }) + ); + }, + { + cacheKeyFn: ({ bodyId, preferredColorId, fallbackColorId }) => + `${bodyId}-${preferredColorId}-${fallbackColorId}`, + } + ); const buildPetStateByPetTypeAndAssetsLoader = (db, loaders) => new DataLoader( diff --git a/src/server/types/Item.js b/src/server/types/Item.js index ab01359..f9b7b35 100644 --- a/src/server/types/Item.js +++ b/src/server/types/Item.js @@ -1,5 +1,11 @@ import { gql } from "apollo-server"; -import { getRestrictedZoneIds, oneWeek, oneDay, oneHour } from "../util"; +import { + getRestrictedZoneIds, + normalizeRow, + oneWeek, + oneDay, + oneHour, +} from "../util"; const typeDefs = gql` type Item @cacheControl(maxAge: ${oneDay}, staleWhileRevalidate: ${oneWeek}) { @@ -56,16 +62,22 @@ const typeDefs = gql` speciesThatNeedModels(colorId: ID): [Species!]! @cacheControl(maxAge: 1) # Return a single ItemAppearance for this item. It'll be for the species - # with the smallest ID for which we have item appearance data. We use this - # on the item page, to initialize the preview section. (You can find out - # which species this is for by going through the body field on - # ItemAppearance!) + # with the smallest ID for which we have item appearance data, and a basic + # color. We use this on the item page, to initialize the preview section. + # (You can find out which species this is for by going through the body + # field on ItemAppearance!) # - # There's also an optional preferredSpeciesId field, which you can use to - # request a certain species if compatible. If not, we'll fall back to the - # default species, as described above. - canonicalAppearance(preferredSpeciesId: ID): ItemAppearance @cacheControl(maxAge: 1, staleWhileRevalidate: ${oneWeek}) - + # There's also optional fields preferredSpeciesId and preferredColorId, to + # request a certain species or color if possible. We'll try to match each, + # with precedence to species first; then fall back to the canonical values. + # + # Note that the exact choice of color doesn't usually affect this field, + # because ItemAppearance is per-body rather than per-color. It's most + # relevant for special colors like Baby or Mutant. But the + # canonicalAppearance on the Body type _does_ use the preferred color more + # precisely! + canonicalAppearance(preferredSpeciesId: ID, preferredColorId: ID): ItemAppearance @cacheControl(maxAge: 1, staleWhileRevalidate: ${oneWeek}) + # All zones that this item occupies, for at least one body. That is, it's # a union of zones for all of its appearances! We use this for overview # info about the item. @@ -317,14 +329,37 @@ const resolvers = { }, canonicalAppearance: async ( { id }, - { preferredSpeciesId }, - { itemBodiesWithAppearanceDataLoader } + { preferredSpeciesId, preferredColorId }, + { db } ) => { - const rows = await itemBodiesWithAppearanceDataLoader.load(id); - const preferredRow = preferredSpeciesId - ? rows.find((row) => row.speciesId === preferredSpeciesId) - : null; - const bestRow = preferredRow || rows[0]; + const [rows, _] = await db.query( + ` + SELECT pet_types.body_id, pet_types.species_id FROM pet_types + INNER JOIN colors ON + pet_types.color_id = colors.id + INNER JOIN swf_assets ON + pet_types.body_id = swf_assets.body_id OR swf_assets.body_id = 0 + INNER JOIN parents_swf_assets ON + parents_swf_assets.swf_asset_id = swf_assets.id + INNER JOIN items ON + items.id = parents_swf_assets.parent_id AND + parents_swf_assets.parent_type = "Item" + WHERE items.id = ? + ORDER BY + pet_types.species_id = ? DESC, + pet_types.color_id = ? DESC, + pet_types.species_id ASC, + colors.standard DESC + LIMIT 1 + `, + [id, preferredSpeciesId || "", preferredColorId || ""] + ); + if (rows.length === 0) { + return null; + } + + const bestRow = normalizeRow(rows[0]); + return { item: { id }, bodyId: bestRow.bodyId, diff --git a/src/server/types/PetAppearance.js b/src/server/types/PetAppearance.js index 41cd61f..3803493 100644 --- a/src/server/types/PetAppearance.js +++ b/src/server/types/PetAppearance.js @@ -47,8 +47,9 @@ const typeDefs = gql` id: ID! species: Species! - # A PetAppearance that has this body. Prefers Blue and happy poses. - canonicalAppearance: PetAppearance + # A PetAppearance that has this body. Prefers Blue (or the optional + # preferredColorId), and happy poses. + canonicalAppearance(preferredColorId: ID): PetAppearance # Whether this is the special body type that represents fitting _all_ pets. representsAllBodies: Boolean! @@ -167,11 +168,15 @@ const resolvers = { return id == "0"; }, canonicalAppearance: async ( - { id }, - _, + { id, species }, + { preferredColorId }, { canonicalPetStateForBodyLoader } ) => { - const petState = await canonicalPetStateForBodyLoader.load(id); + const petState = await canonicalPetStateForBodyLoader.load({ + bodyId: id, + preferredColorId, + fallbackColorId: FALLBACK_COLOR_IDS[species.id] || "8", + }); if (!petState) { return null; } @@ -309,4 +314,64 @@ const resolvers = { }, }; +// NOTE: This matches the colors on ItemPage, so that they always match! +const colors = { BLUE: "8", RED: "61", GREEN: "34", YELLOW: "84" }; +const FALLBACK_COLOR_IDS = { + "1": colors.GREEN, // Acara + "2": colors.BLUE, // Aisha + "3": colors.YELLOW, // Blumaroo + "4": colors.YELLOW, // Bori + "5": colors.YELLOW, // Bruce + "6": colors.YELLOW, // Buzz + "7": colors.RED, // Chia + "8": colors.YELLOW, // Chomby + "9": colors.GREEN, // Cybunny + "10": colors.YELLOW, // Draik + "11": colors.RED, // Elephante + "12": colors.RED, // Eyrie + "13": colors.GREEN, // Flotsam + "14": colors.YELLOW, // Gelert + "15": colors.BLUE, // Gnorbu + "16": colors.BLUE, // Grarrl + "17": colors.GREEN, // Grundo + "18": colors.RED, // Hissi + "19": colors.GREEN, // Ixi + "20": colors.YELLOW, // Jetsam + "21": colors.GREEN, // Jubjub + "22": colors.YELLOW, // Kacheek + "23": colors.BLUE, // Kau + "24": colors.GREEN, // Kiko + "25": colors.GREEN, // Koi + "26": colors.RED, // Korbat + "27": colors.BLUE, // Kougra + "28": colors.BLUE, // Krawk + "29": colors.YELLOW, // Kyrii + "30": colors.YELLOW, // Lenny + "31": colors.YELLOW, // Lupe + "32": colors.BLUE, // Lutari + "33": colors.YELLOW, // Meerca + "34": colors.GREEN, // Moehog + "35": colors.BLUE, // Mynci + "36": colors.BLUE, // Nimmo + "37": colors.YELLOW, // Ogrin + "38": colors.RED, // Peophin + "39": colors.GREEN, // Poogle + "40": colors.RED, // Pteri + "41": colors.YELLOW, // Quiggle + "42": colors.BLUE, // Ruki + "43": colors.RED, // Scorchio + "44": colors.YELLOW, // Shoyru + "45": colors.RED, // Skeith + "46": colors.YELLOW, // Techo + "47": colors.BLUE, // Tonu + "48": colors.YELLOW, // Tuskaninny + "49": colors.GREEN, // Uni + "50": colors.RED, // Usul + "55": colors.YELLOW, // Vandagyre + "51": colors.YELLOW, // Wocky + "52": colors.RED, // Xweetok + "53": colors.RED, // Yurble + "54": colors.BLUE, // Zafara +}; + module.exports = { typeDefs, resolvers };