2020-04-22 13:03:32 -07:00
|
|
|
const { gql } = require("apollo-server");
|
2020-04-22 11:51:36 -07:00
|
|
|
|
|
|
|
const connectToDb = require("./db");
|
2020-04-23 14:23:46 -07:00
|
|
|
const buildLoaders = require("./loaders");
|
2020-04-25 06:50:34 -07:00
|
|
|
const neopets = require("./neopets");
|
2020-05-02 22:32:08 -07:00
|
|
|
const { capitalize, getEmotion, getGenderPresentation } = require("./util");
|
2020-04-22 11:51:36 -07:00
|
|
|
|
|
|
|
const typeDefs = gql`
|
2020-04-23 01:08:00 -07:00
|
|
|
enum LayerImageSize {
|
|
|
|
SIZE_600
|
|
|
|
SIZE_300
|
|
|
|
SIZE_150
|
|
|
|
}
|
|
|
|
|
2020-05-02 16:49:57 -07:00
|
|
|
"""
|
|
|
|
A pet's gender presentation: masculine or feminine.
|
|
|
|
|
|
|
|
Neopets calls these "male" and "female", and I think that's silly and not wise
|
|
|
|
to propagate further, especially in the context of a strictly visual app like
|
|
|
|
Dress to Impress! This description isn't altogether correct either, but idk
|
|
|
|
what's better :/
|
|
|
|
"""
|
|
|
|
enum GenderPresentation {
|
|
|
|
MASCULINE
|
|
|
|
FEMININE
|
|
|
|
}
|
|
|
|
|
|
|
|
"""
|
|
|
|
A pet's emotion: happy, sad, or sick.
|
|
|
|
|
|
|
|
Note that we don't ever show the angry emotion on Dress to Impress, because
|
|
|
|
we don't have the data: it's impossible for a pet's passive emotion on the
|
|
|
|
pet lookup to be angry!
|
|
|
|
"""
|
|
|
|
enum Emotion {
|
|
|
|
HAPPY
|
|
|
|
SAD
|
|
|
|
SICK
|
|
|
|
}
|
|
|
|
|
2020-04-22 11:51:36 -07:00
|
|
|
type Item {
|
|
|
|
id: ID!
|
|
|
|
name: String!
|
2020-04-25 22:40:28 -07:00
|
|
|
description: String!
|
2020-04-22 14:55:12 -07:00
|
|
|
thumbnailUrl: String!
|
2020-05-02 16:49:57 -07:00
|
|
|
appearanceOn(speciesId: ID!, colorId: ID!): ItemAppearance
|
2020-04-23 01:08:00 -07:00
|
|
|
}
|
|
|
|
|
2020-05-02 16:49:57 -07:00
|
|
|
type PetAppearance {
|
2020-05-02 20:48:32 -07:00
|
|
|
id: ID!
|
2020-05-02 22:37:52 -07:00
|
|
|
petStateId: ID!
|
2020-05-03 12:55:37 -07:00
|
|
|
bodyId: ID!
|
2020-05-02 16:49:57 -07:00
|
|
|
genderPresentation: GenderPresentation
|
|
|
|
emotion: Emotion
|
2020-05-02 20:48:32 -07:00
|
|
|
approximateThumbnailUrl: String!
|
2020-05-02 16:49:57 -07:00
|
|
|
layers: [AppearanceLayer!]!
|
|
|
|
}
|
|
|
|
|
|
|
|
type ItemAppearance {
|
2020-04-23 14:23:46 -07:00
|
|
|
layers: [AppearanceLayer!]!
|
2020-04-23 14:44:06 -07:00
|
|
|
restrictedZones: [Zone!]!
|
2020-04-23 01:08:00 -07:00
|
|
|
}
|
|
|
|
|
2020-04-23 14:23:46 -07:00
|
|
|
type AppearanceLayer {
|
2020-04-23 01:08:00 -07:00
|
|
|
id: ID!
|
|
|
|
zone: Zone!
|
|
|
|
imageUrl(size: LayerImageSize): String
|
2020-05-11 21:19:34 -07:00
|
|
|
|
|
|
|
"""
|
|
|
|
This layer as a single SVG, if available.
|
|
|
|
|
|
|
|
This might not be available if the asset isn't converted yet by Neopets,
|
|
|
|
or if it's not as simple as a single SVG (e.g. animated).
|
|
|
|
"""
|
|
|
|
svgUrl: String
|
2020-04-23 01:08:00 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
type Zone {
|
|
|
|
id: ID!
|
|
|
|
depth: Int!
|
|
|
|
label: String!
|
2020-04-22 11:51:36 -07:00
|
|
|
}
|
|
|
|
|
2020-04-25 01:55:48 -07:00
|
|
|
type ItemSearchResult {
|
|
|
|
query: String!
|
|
|
|
items: [Item!]!
|
|
|
|
}
|
|
|
|
|
2020-04-25 03:42:05 -07:00
|
|
|
type Color {
|
|
|
|
id: ID!
|
|
|
|
name: String!
|
|
|
|
}
|
|
|
|
|
|
|
|
type Species {
|
|
|
|
id: ID!
|
|
|
|
name: String!
|
|
|
|
}
|
|
|
|
|
|
|
|
type SpeciesColorPair {
|
|
|
|
species: Species!
|
|
|
|
color: Color!
|
|
|
|
}
|
|
|
|
|
2020-04-25 06:50:34 -07:00
|
|
|
type Outfit {
|
|
|
|
species: Species!
|
|
|
|
color: Color!
|
|
|
|
items: [Item!]!
|
|
|
|
}
|
|
|
|
|
2020-04-22 11:51:36 -07:00
|
|
|
type Query {
|
2020-05-14 15:51:08 -07:00
|
|
|
allColors: [Color!]! @cacheControl(maxAge: 10800) # Cache for 3 hours
|
|
|
|
allSpecies: [Species!]! @cacheControl(maxAge: 10800) # Cache for 3 hours
|
2020-05-03 01:52:39 -07:00
|
|
|
allValidSpeciesColorPairs: [SpeciesColorPair!]! # deprecated
|
2020-04-22 11:51:36 -07:00
|
|
|
items(ids: [ID!]!): [Item!]!
|
2020-04-25 01:55:48 -07:00
|
|
|
itemSearch(query: String!): ItemSearchResult!
|
|
|
|
itemSearchToFit(
|
|
|
|
query: String!
|
|
|
|
speciesId: ID!
|
|
|
|
colorId: ID!
|
|
|
|
offset: Int
|
|
|
|
limit: Int
|
|
|
|
): ItemSearchResult!
|
2020-05-02 22:32:08 -07:00
|
|
|
petAppearance(
|
|
|
|
speciesId: ID!
|
|
|
|
colorId: ID!
|
|
|
|
emotion: Emotion!
|
|
|
|
genderPresentation: GenderPresentation!
|
|
|
|
): PetAppearance
|
2020-05-02 16:49:57 -07:00
|
|
|
petAppearances(speciesId: ID!, colorId: ID!): [PetAppearance!]!
|
2020-04-25 06:50:34 -07:00
|
|
|
|
|
|
|
petOnNeopetsDotCom(petName: String!): Outfit
|
2020-04-22 11:51:36 -07:00
|
|
|
}
|
|
|
|
`;
|
|
|
|
|
|
|
|
const resolvers = {
|
|
|
|
Item: {
|
2020-04-22 12:00:52 -07:00
|
|
|
name: async (item, _, { itemTranslationLoader }) => {
|
2020-04-25 00:43:01 -07:00
|
|
|
// Search queries pre-fill this!
|
|
|
|
if (item.name) return item.name;
|
|
|
|
|
2020-04-22 12:00:52 -07:00
|
|
|
const translation = await itemTranslationLoader.load(item.id);
|
2020-04-22 11:51:36 -07:00
|
|
|
return translation.name;
|
|
|
|
},
|
2020-04-25 22:40:28 -07:00
|
|
|
description: async (item, _, { itemTranslationLoader }) => {
|
|
|
|
const translation = await itemTranslationLoader.load(item.id);
|
|
|
|
return translation.description;
|
|
|
|
},
|
2020-04-23 14:23:46 -07:00
|
|
|
appearanceOn: async (
|
|
|
|
item,
|
|
|
|
{ speciesId, colorId },
|
|
|
|
{ petTypeLoader, itemSwfAssetLoader }
|
|
|
|
) => {
|
2020-04-23 01:08:00 -07:00
|
|
|
const petType = await petTypeLoader.load({
|
2020-04-23 14:23:46 -07:00
|
|
|
speciesId: speciesId,
|
|
|
|
colorId: colorId,
|
2020-04-23 01:08:00 -07:00
|
|
|
});
|
2020-04-23 14:23:46 -07:00
|
|
|
const swfAssets = await itemSwfAssetLoader.load({
|
|
|
|
itemId: item.id,
|
2020-04-23 01:08:00 -07:00
|
|
|
bodyId: petType.bodyId,
|
|
|
|
});
|
2020-04-23 14:44:06 -07:00
|
|
|
|
2020-04-25 04:38:55 -07:00
|
|
|
if (swfAssets.length === 0) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2020-04-23 14:44:06 -07:00
|
|
|
const restrictedZones = [];
|
|
|
|
for (const [i, bit] of Array.from(item.zonesRestrict).entries()) {
|
|
|
|
if (bit === "1") {
|
|
|
|
const zone = { id: i + 1 };
|
|
|
|
restrictedZones.push(zone);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return { layers: swfAssets, restrictedZones };
|
2020-04-23 01:08:00 -07:00
|
|
|
},
|
|
|
|
},
|
2020-05-02 16:49:57 -07:00
|
|
|
PetAppearance: {
|
2020-05-02 22:37:52 -07:00
|
|
|
id: ({ petType, petState }) => {
|
|
|
|
const { speciesId, colorId } = petType;
|
|
|
|
const emotion = getEmotion(petState.moodId);
|
|
|
|
const genderPresentation = getGenderPresentation(petState.female);
|
|
|
|
return `${speciesId}-${colorId}-${emotion}-${genderPresentation}`;
|
|
|
|
},
|
|
|
|
petStateId: ({ petState }) => petState.id,
|
2020-05-03 12:55:37 -07:00
|
|
|
bodyId: ({ petType }) => petType.bodyId,
|
2020-05-02 22:32:08 -07:00
|
|
|
genderPresentation: ({ petState }) =>
|
|
|
|
getGenderPresentation(petState.female),
|
|
|
|
emotion: ({ petState }) => getEmotion(petState.moodId),
|
2020-05-02 20:48:32 -07:00
|
|
|
approximateThumbnailUrl: ({ petType, petState }) => {
|
|
|
|
return `http://pets.neopets.com/cp/${petType.basicImageHash}/${petState.moodId}/1.png`;
|
|
|
|
},
|
2020-05-02 16:49:57 -07:00
|
|
|
layers: async ({ petState }, _, { petSwfAssetLoader }) => {
|
|
|
|
const swfAssets = await petSwfAssetLoader.load(petState.id);
|
|
|
|
return swfAssets;
|
|
|
|
},
|
|
|
|
},
|
2020-04-23 14:23:46 -07:00
|
|
|
AppearanceLayer: {
|
2020-04-23 01:08:00 -07:00
|
|
|
zone: async (layer, _, { zoneLoader }) => {
|
|
|
|
const zone = await zoneLoader.load(layer.zoneId);
|
|
|
|
return zone;
|
|
|
|
},
|
|
|
|
imageUrl: (layer, { size }) => {
|
|
|
|
if (!layer.hasImage) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const sizeNum = size.split("_")[1];
|
|
|
|
|
|
|
|
const rid = layer.remoteId;
|
|
|
|
const paddedId = rid.padStart(12, "0");
|
|
|
|
const rid1 = paddedId.slice(0, 3);
|
|
|
|
const rid2 = paddedId.slice(3, 6);
|
|
|
|
const rid3 = paddedId.slice(6, 9);
|
|
|
|
const time = Number(new Date(layer.convertedAt));
|
|
|
|
|
2020-04-23 14:23:46 -07:00
|
|
|
return (
|
|
|
|
`https://impress-asset-images.s3.amazonaws.com/${layer.type}` +
|
2020-04-24 21:38:18 -07:00
|
|
|
`/${rid1}/${rid2}/${rid3}/${rid}/${sizeNum}x${sizeNum}.png?v2-${time}`
|
2020-04-23 14:23:46 -07:00
|
|
|
);
|
2020-04-23 01:08:00 -07:00
|
|
|
},
|
2020-05-11 21:19:34 -07:00
|
|
|
svgUrl: async (layer) => {
|
|
|
|
const manifest = await neopets.loadAssetManifest(layer.url);
|
|
|
|
if (!manifest) {
|
|
|
|
console.debug("expected manifest to exist, but it did not");
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (manifest.assets.length !== 1) {
|
|
|
|
console.debug(
|
|
|
|
"expected 1 asset in manifest, but found %d",
|
|
|
|
manifest.assets.length
|
|
|
|
);
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const asset = manifest.assets[0];
|
|
|
|
if (asset.format !== "vector") {
|
|
|
|
console.debug(
|
|
|
|
'expected asset format "vector", but found %s',
|
|
|
|
asset.format
|
|
|
|
);
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (asset.assetData.length !== 1) {
|
|
|
|
console.debug(
|
|
|
|
"expected 1 datum in asset, but found %d",
|
|
|
|
asset.assetData.length
|
|
|
|
);
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const assetDatum = asset.assetData[0];
|
|
|
|
const url = new URL(assetDatum.path, "http://images.neopets.com");
|
|
|
|
return url.toString();
|
|
|
|
},
|
2020-04-23 01:08:00 -07:00
|
|
|
},
|
|
|
|
Zone: {
|
|
|
|
label: async (zone, _, { zoneTranslationLoader }) => {
|
|
|
|
const zoneTranslation = await zoneTranslationLoader.load(zone.id);
|
|
|
|
return zoneTranslation.label;
|
|
|
|
},
|
2020-04-22 11:51:36 -07:00
|
|
|
},
|
2020-04-25 03:42:05 -07:00
|
|
|
Color: {
|
|
|
|
name: async (color, _, { colorTranslationLoader }) => {
|
|
|
|
const colorTranslation = await colorTranslationLoader.load(color.id);
|
2020-04-25 04:33:05 -07:00
|
|
|
return capitalize(colorTranslation.name);
|
2020-04-25 03:42:05 -07:00
|
|
|
},
|
|
|
|
},
|
|
|
|
Species: {
|
|
|
|
name: async (species, _, { speciesTranslationLoader }) => {
|
|
|
|
const speciesTranslation = await speciesTranslationLoader.load(
|
|
|
|
species.id
|
|
|
|
);
|
2020-04-25 04:33:05 -07:00
|
|
|
return capitalize(speciesTranslation.name);
|
2020-04-25 03:42:05 -07:00
|
|
|
},
|
|
|
|
},
|
2020-04-22 11:51:36 -07:00
|
|
|
Query: {
|
2020-04-25 03:42:05 -07:00
|
|
|
allColors: async (_, { ids }, { loadAllColors }) => {
|
|
|
|
const allColors = await loadAllColors();
|
|
|
|
return allColors;
|
|
|
|
},
|
|
|
|
allSpecies: async (_, { ids }, { loadAllSpecies }) => {
|
|
|
|
const allSpecies = await loadAllSpecies();
|
|
|
|
return allSpecies;
|
|
|
|
},
|
|
|
|
allValidSpeciesColorPairs: async (_, __, { loadAllPetTypes }) => {
|
|
|
|
const allPetTypes = await loadAllPetTypes();
|
|
|
|
const allPairs = allPetTypes.map((pt) => ({
|
|
|
|
color: { id: pt.colorId },
|
|
|
|
species: { id: pt.speciesId },
|
|
|
|
}));
|
|
|
|
return allPairs;
|
|
|
|
},
|
2020-04-23 14:23:46 -07:00
|
|
|
items: async (_, { ids }, { itemLoader }) => {
|
|
|
|
const items = await itemLoader.loadMany(ids);
|
2020-04-23 01:08:00 -07:00
|
|
|
return items;
|
2020-04-24 21:17:03 -07:00
|
|
|
},
|
|
|
|
itemSearch: async (_, { query }, { itemSearchLoader }) => {
|
|
|
|
const items = await itemSearchLoader.load(query);
|
2020-04-25 01:55:48 -07:00
|
|
|
return { query, items };
|
2020-04-25 00:43:01 -07:00
|
|
|
},
|
|
|
|
itemSearchToFit: async (
|
|
|
|
_,
|
2020-04-25 01:55:48 -07:00
|
|
|
{ query, speciesId, colorId, offset, limit },
|
2020-04-25 00:43:01 -07:00
|
|
|
{ petTypeLoader, itemSearchToFitLoader }
|
|
|
|
) => {
|
|
|
|
const petType = await petTypeLoader.load({ speciesId, colorId });
|
|
|
|
const { bodyId } = petType;
|
2020-04-25 01:55:48 -07:00
|
|
|
const items = await itemSearchToFitLoader.load({
|
|
|
|
query,
|
|
|
|
bodyId,
|
|
|
|
offset,
|
|
|
|
limit,
|
|
|
|
});
|
|
|
|
return { query, items };
|
2020-04-23 01:08:00 -07:00
|
|
|
},
|
2020-04-23 14:23:46 -07:00
|
|
|
petAppearance: async (
|
|
|
|
_,
|
2020-05-02 22:32:08 -07:00
|
|
|
{ speciesId, colorId, emotion, genderPresentation },
|
2020-05-02 20:48:32 -07:00
|
|
|
{ petTypeLoader, petStateLoader }
|
2020-04-23 14:23:46 -07:00
|
|
|
) => {
|
|
|
|
const petType = await petTypeLoader.load({
|
|
|
|
speciesId,
|
|
|
|
colorId,
|
|
|
|
});
|
2020-05-02 22:32:08 -07:00
|
|
|
|
2020-04-23 14:23:46 -07:00
|
|
|
const petStates = await petStateLoader.load(petType.id);
|
2020-05-02 22:32:08 -07:00
|
|
|
// TODO: This could be optimized into the query condition 🤔
|
|
|
|
const petState = petStates.find(
|
|
|
|
(ps) =>
|
|
|
|
getEmotion(ps.moodId) === emotion &&
|
|
|
|
getGenderPresentation(ps.female) === genderPresentation
|
|
|
|
);
|
|
|
|
if (!petState) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return { petType, petState };
|
2020-05-02 16:49:57 -07:00
|
|
|
},
|
|
|
|
petAppearances: async (
|
|
|
|
_,
|
|
|
|
{ speciesId, colorId },
|
2020-05-02 20:48:32 -07:00
|
|
|
{ petTypeLoader, petStateLoader }
|
2020-05-02 16:49:57 -07:00
|
|
|
) => {
|
|
|
|
const petType = await petTypeLoader.load({
|
|
|
|
speciesId,
|
|
|
|
colorId,
|
|
|
|
});
|
|
|
|
const petStates = await petStateLoader.load(petType.id);
|
2020-05-02 20:48:32 -07:00
|
|
|
return petStates.map((petState) => ({ petType, petState }));
|
2020-04-23 14:23:46 -07:00
|
|
|
},
|
2020-04-25 06:50:34 -07:00
|
|
|
petOnNeopetsDotCom: async (_, { petName }) => {
|
|
|
|
const petData = await neopets.loadPetData(petName);
|
|
|
|
const outfit = {
|
|
|
|
species: { id: petData.custom_pet.species_id },
|
|
|
|
color: { id: petData.custom_pet.color_id },
|
|
|
|
items: Object.values(petData.object_info_registry).map((o) => ({
|
|
|
|
id: o.obj_info_id,
|
|
|
|
})),
|
|
|
|
};
|
|
|
|
return outfit;
|
|
|
|
},
|
2020-04-22 11:51:36 -07:00
|
|
|
},
|
|
|
|
};
|
|
|
|
|
2020-04-22 13:03:32 -07:00
|
|
|
const config = {
|
2020-04-22 11:51:36 -07:00
|
|
|
typeDefs,
|
|
|
|
resolvers,
|
|
|
|
context: async () => {
|
|
|
|
const db = await connectToDb();
|
2020-04-22 12:00:52 -07:00
|
|
|
return {
|
2020-04-23 14:23:46 -07:00
|
|
|
...buildLoaders(db),
|
2020-04-22 12:00:52 -07:00
|
|
|
};
|
2020-04-22 11:51:36 -07:00
|
|
|
},
|
2020-04-23 01:09:17 -07:00
|
|
|
|
|
|
|
// Enable Playground in production :)
|
|
|
|
introspection: true,
|
2020-04-23 01:12:52 -07:00
|
|
|
playground: {
|
|
|
|
endpoint: "/api/graphql",
|
|
|
|
},
|
2020-04-22 13:03:32 -07:00
|
|
|
};
|
2020-04-22 11:51:36 -07:00
|
|
|
|
|
|
|
if (require.main === module) {
|
2020-04-22 13:03:32 -07:00
|
|
|
const { ApolloServer } = require("apollo-server");
|
|
|
|
const server = new ApolloServer(config);
|
2020-04-22 11:51:36 -07:00
|
|
|
server.listen().then(({ url }) => {
|
|
|
|
console.log(`🚀 Server ready at ${url}`);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-04-22 13:03:32 -07:00
|
|
|
module.exports = { config };
|