impress-2020/src/server/index.js

1194 lines
36 KiB
JavaScript
Raw Normal View History

const { gql, makeExecutableSchema } = require("apollo-server");
import { addBeelineToSchema, beelinePlugin } from "./lib/beeline-graphql";
2020-04-22 11:51:36 -07:00
const connectToDb = require("./db");
const buildLoaders = require("./loaders");
const neopets = require("./neopets");
2020-05-23 12:47:06 -07:00
const {
capitalize,
getPoseFromPetState,
getPetStateFieldsFromPose,
getPoseFromPetData,
2020-05-23 12:47:06 -07:00
getEmotion,
getGenderPresentation,
getPoseName,
loadBodyName,
logToDiscord,
normalizeRow,
2020-05-23 12:47:06 -07:00
} = require("./util");
2020-04-22 11:51:36 -07:00
const typeDefs = gql`
directive @cacheControl(maxAge: Int!) on FIELD_DEFINITION | OBJECT
2020-04-23 01:08:00 -07:00
enum LayerImageSize {
SIZE_600
SIZE_300
SIZE_150
}
2020-05-23 12:47:06 -07:00
"""
The poses a PetAppearance can take!
"""
enum Pose {
HAPPY_MASC
SAD_MASC
SICK_MASC
HAPPY_FEM
SAD_FEM
SICK_FEM
UNCONVERTED
UNKNOWN # for when we have the data, but we don't know what it is
}
"""
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!
description: String!
2020-04-22 14:55:12 -07:00
thumbnailUrl: String!
2020-05-31 15:56:40 -07:00
rarityIndex: Int!
isNc: Boolean!
# How this item appears on the given species/color combo. If it does not
# fit the pet, we'll return an empty ItemAppearance with no layers.
appearanceOn(speciesId: ID!, colorId: ID!): ItemAppearance!
# This is set manually by Support users, when the pet is only for e.g.
# Maraquan pets, and our usual auto-detection isn't working. We provide
# this for the Support UI; it's not very helpful for most users, because it
# can be empty even if the item _has_ an auto-detected special color.
manualSpecialColor: Color
# This is set manually by Support users, when the item _seems_ to fit all
# pets the same because of its zones, but it actually doesn't - e.g.,
# the Dug Up Dirt Foreground actually looks different for each body. We
# provide this for the Support UI; it's not very helpful for most users,
# because it's only used at modeling time. This value does not change how
# layer data from this API should be interpreted!
explicitlyBodySpecific: Boolean!
2020-04-23 01:08:00 -07:00
}
# Cache for 1 week (unlikely to change)
type PetAppearance @cacheControl(maxAge: 604800) {
2020-05-02 20:48:32 -07:00
id: ID!
2020-06-24 19:05:07 -07:00
species: Species!
color: Color!
2020-05-23 12:47:06 -07:00
pose: Pose!
2020-06-24 19:05:07 -07:00
bodyId: ID!
layers: [AppearanceLayer!]!
petStateId: ID! # Deprecated, an alias for id
# Whether this PetAppearance is known to look incorrect. This is a manual
# flag that we set, in the case where this glitchy PetAppearance really did
# appear on Neopets.com, and has since been fixed.
isGlitched: Boolean!
}
type ItemAppearance {
id: ID!
item: Item!
bodyId: ID!
layers: [AppearanceLayer!]
2020-04-23 14:44:06 -07:00
restrictedZones: [Zone!]!
2020-04-23 01:08:00 -07:00
}
# Cache for 1 week (unlikely to change)
type AppearanceLayer @cacheControl(maxAge: 604800) {
2020-08-14 22:09:52 -07:00
# The DTI ID. Guaranteed unique across all layers of all types.
2020-04-23 01:08:00 -07:00
id: ID!
2020-08-14 22:09:52 -07:00
# The Neopets ID. Guaranteed unique across layers of the _same_ type, but
# not of different types. That is, it's allowed and common for an item
# layer and a pet layer to have the same remoteId.
remoteId: ID!
2020-04-23 01:08:00 -07:00
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
"""
This layer as a single SWF, if available.
At time of writing, all layers have SWFs. But I've marked this nullable
because I'm not sure this will continue to be true after the HTML5
migration, and I'd like clients to guard against it.
"""
swfUrl: String
"""
This layer can fit on PetAppearances with the same bodyId. "0" is a
special body ID that indicates it fits all PetAppearances.
"""
bodyId: ID!
"""
The item this layer is for, if any. (For pet layers, this is null.)
"""
item: Item
2020-04-23 01:08:00 -07:00
}
# Cache for 1 week (unlikely to change)
type Zone @cacheControl(maxAge: 604800) {
2020-04-23 01:08:00 -07:00
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!]!
}
# Cache for 1 week (unlikely to change)
type Color @cacheControl(maxAge: 604800) {
2020-04-25 03:42:05 -07:00
id: ID!
name: String!
2020-07-31 22:11:32 -07:00
isStandard: Boolean!
2020-04-25 03:42:05 -07:00
}
# Cache for 1 week (unlikely to change)
type Species @cacheControl(maxAge: 604800) {
2020-04-25 03:42:05 -07:00
id: ID!
name: String!
}
type SpeciesColorPair {
species: Species!
color: Color!
}
type Outfit {
2020-06-24 19:05:07 -07:00
id: ID!
name: String!
petAppearance: PetAppearance!
wornItems: [Item!]!
closetedItems: [Item!]!
species: Species! # to be deprecated? can use petAppearance? 🤔
color: Color! # to be deprecated? can use petAppearance? 🤔
pose: Pose! # to be deprecated? can use petAppearance? 🤔
items: [Item!]! # deprecated alias for wornItems
}
2020-04-22 11:51:36 -07:00
type Query {
allColors: [Color!]! @cacheControl(maxAge: 10800) # Cache for 3 hours (we might add more!)
allSpecies: [Species!]! @cacheControl(maxAge: 10800) # Cache for 3 hours (we might add more!)
allValidSpeciesColorPairs: [SpeciesColorPair!]! # deprecated
item(id: ID!): Item
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!
petAppearanceById(id: ID!): PetAppearance @cacheControl(maxAge: 10800) # Cache for 3 hours (Support might edit!)
# The canonical pet appearance for the given species, color, and pose.
# Null if we don't have any data for this combination.
2020-05-23 12:47:06 -07:00
petAppearance(speciesId: ID!, colorId: ID!, pose: Pose!): PetAppearance
@cacheControl(maxAge: 10800) # Cache for 3 hours (we might model more!)
# All pet appearances we've ever seen for the given species and color. Note
# that this might include multiple copies for the same pose, and they might
# even be glitched data. We use this for Support tools.
petAppearances(speciesId: ID!, colorId: ID!): [PetAppearance!]!
@cacheControl(maxAge: 10800) # Cache for 3 hours (we might model more!)
2020-06-24 19:05:07 -07:00
outfit(id: ID!): Outfit
petOnNeopetsDotCom(petName: String!): Outfit
2020-04-22 11:51:36 -07:00
}
type RemoveLayerFromItemMutationResult {
layer: AppearanceLayer!
item: Item!
}
type Mutation {
setManualSpecialColor(
itemId: ID!
colorId: ID
supportSecret: String!
): Item!
setItemExplicitlyBodySpecific(
itemId: ID!
explicitlyBodySpecific: Boolean!
supportSecret: String!
): Item!
setLayerBodyId(
layerId: ID!
bodyId: ID!
supportSecret: String!
): AppearanceLayer!
removeLayerFromItem(
layerId: ID!
itemId: ID!
supportSecret: String!
): RemoveLayerFromItemMutationResult!
setPetAppearancePose(
appearanceId: ID!
pose: Pose!
supportSecret: String!
): PetAppearance!
setPetAppearanceIsGlitched(
appearanceId: ID!
isGlitched: Boolean!
supportSecret: String!
): PetAppearance!
}
2020-04-22 11:51:36 -07:00
`;
const resolvers = {
Item: {
name: async ({ id, name }, _, { itemTranslationLoader }) => {
if (name) return name;
const translation = await itemTranslationLoader.load(id);
2020-04-22 11:51:36 -07:00
return translation.name;
},
description: async ({ id, description }, _, { itemTranslationLoader }) => {
if (description) return description;
const translation = await itemTranslationLoader.load(id);
return translation.description;
},
thumbnailUrl: async ({ id, thumbnailUrl }, _, { itemLoader }) => {
if (thumbnailUrl) return thumbnailUrl;
const item = await itemLoader.load(id);
return item.thumbnailUrl;
},
rarityIndex: async ({ id, rarityIndex }, _, { itemLoader }) => {
if (rarityIndex) return rarityIndex;
const item = await itemLoader.load(id);
return item.rarityIndex;
},
isNc: async ({ id, rarityIndex }, _, { itemLoader }) => {
if (rarityIndex != null) return rarityIndex === 500 || rarityIndex === 0;
const item = await itemLoader.load(id);
return item.rarityIndex === 500 || item.rarityIndex === 0;
},
appearanceOn: async (
{ id },
{ speciesId, colorId },
{ petTypeBySpeciesAndColorLoader }
) => {
2020-06-24 19:05:07 -07:00
const petType = await petTypeBySpeciesAndColorLoader.load({
speciesId,
colorId,
2020-04-23 01:08:00 -07:00
});
return { item: { id }, bodyId: petType.bodyId };
2020-04-23 01:08:00 -07:00
},
manualSpecialColor: async ({ id }, _, { itemLoader }) => {
const item = await itemLoader.load(id);
return item.manualSpecialColorId != null
? { id: item.manualSpecialColorId }
: null;
},
explicitlyBodySpecific: async ({ id }, _, { itemLoader }) => {
const item = await itemLoader.load(id);
return item.explicitlyBodySpecific;
},
2020-04-23 01:08:00 -07:00
},
ItemAppearance: {
id: ({ item, bodyId }) => `item-${item.id}-body-${bodyId}`,
layers: async ({ item, bodyId }, _, { itemSwfAssetLoader }) => {
const allSwfAssets = await itemSwfAssetLoader.load({
itemId: item.id,
bodyId,
});
return allSwfAssets.filter((sa) => sa.url.endsWith(".swf"));
},
restrictedZones: async ({ item: { id: itemId } }, _, { itemLoader }) => {
const item = await itemLoader.load(itemId);
const restrictedZones = [];
for (const [i, bit] of Array.from(item.zonesRestrict).entries()) {
if (bit === "1") {
const zone = { id: i + 1 };
restrictedZones.push(zone);
}
}
return restrictedZones;
},
},
PetAppearance: {
color: async ({ id }, _, { petStateLoader, petTypeLoader }) => {
const petState = await petStateLoader.load(id);
2020-06-24 19:05:07 -07:00
const petType = await petTypeLoader.load(petState.petTypeId);
return { id: petType.colorId };
2020-05-02 20:48:32 -07:00
},
species: async ({ id }, _, { petStateLoader, petTypeLoader }) => {
const petState = await petStateLoader.load(id);
2020-06-24 19:05:07 -07:00
const petType = await petTypeLoader.load(petState.petTypeId);
return { id: petType.speciesId };
},
bodyId: async ({ id }, _, { petStateLoader, petTypeLoader }) => {
const petState = await petStateLoader.load(id);
2020-06-24 19:05:07 -07:00
const petType = await petTypeLoader.load(petState.petTypeId);
return petType.bodyId;
},
pose: async ({ id }, _, { petStateLoader }) => {
const petState = await petStateLoader.load(id);
2020-06-24 19:05:07 -07:00
return getPoseFromPetState(petState);
},
layers: async ({ id }, _, { petSwfAssetLoader }) => {
const swfAssets = await petSwfAssetLoader.load(id);
return swfAssets;
},
petStateId: ({ id }) => id,
isGlitched: async ({ id }, _, { petStateLoader }) => {
const petState = await petStateLoader.load(id);
return petState.glitched;
},
},
AppearanceLayer: {
bodyId: async ({ id }, _, { swfAssetLoader }) => {
2020-08-14 22:09:52 -07:00
const layer = await swfAssetLoader.load(id);
return layer.remoteId;
},
bodyId: async ({ id }, _, { swfAssetLoader }) => {
const layer = await swfAssetLoader.load(id);
return layer.bodyId;
},
zone: async ({ id }, _, { swfAssetLoader, zoneLoader }) => {
const layer = await swfAssetLoader.load(id);
return { id: layer.zoneId };
2020-04-23 01:08:00 -07:00
},
swfUrl: async ({ id }, _, { swfAssetLoader }) => {
const layer = await swfAssetLoader.load(id);
return layer.url;
},
imageUrl: async ({ id }, { size }, { swfAssetLoader }) => {
const layer = await swfAssetLoader.load(id);
2020-04-23 01:08:00 -07:00
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));
return (
`https://impress-asset-images.s3.amazonaws.com/${layer.type}` +
`/${rid1}/${rid2}/${rid3}/${rid}/${sizeNum}x${sizeNum}.png?v2-${time}`
);
2020-04-23 01:08:00 -07:00
},
svgUrl: async ({ id }, _, { db, swfAssetLoader, svgLogger }) => {
const layer = await swfAssetLoader.load(id);
let manifest = layer.manifest && JSON.parse(layer.manifest);
// When the manifest is specifically null, that means we don't know if
// it exists yet. Load it to find out!
if (manifest === null) {
manifest = await neopets.loadAssetManifest(layer.url);
// Then, write the new manifest. We make sure to write an empty string
// if there was no manifest, to signify that it doesn't exist, so we
// don't need to bother looking it up again.
//
// TODO: Someday the manifests will all exist, right? So we'll want to
// reload all the missing ones at that time.
manifest = manifest || "";
const [
result,
] = await db.execute(
`UPDATE swf_assets SET manifest = ? WHERE id = ? LIMIT 1;`,
[manifest, layer.id]
);
if (result.affectedRows !== 1) {
throw new Error(
`Expected to affect 1 asset, but affected ${result.affectedRows}`
);
}
console.log(
`Loaded and saved manifest for ${layer.type} ${layer.remoteId}. ` +
`DTI ID: ${layer.id}. Exists?: ${Boolean(manifest)}`
);
}
2020-05-11 21:19:34 -07:00
if (!manifest) {
2020-05-23 11:32:05 -07:00
svgLogger.log("no-manifest");
2020-05-11 21:19:34 -07:00
return null;
}
if (manifest.assets.length !== 1) {
2020-05-23 11:32:05 -07:00
svgLogger.log(`wrong-asset-count:${manifest.assets.length}!=1`);
2020-05-11 21:19:34 -07:00
return null;
}
const asset = manifest.assets[0];
if (asset.format !== "vector") {
2020-05-23 11:32:05 -07:00
svgLogger.log(`wrong-asset-format:${asset.format}`);
2020-05-11 21:19:34 -07:00
return null;
}
if (asset.assetData.length !== 1) {
2020-05-23 11:32:05 -07:00
svgLogger.log(`wrong-assetData-length:${asset.assetData.length}!=1`);
2020-05-11 21:19:34 -07:00
return null;
}
2020-05-23 11:32:05 -07:00
svgLogger.log("success");
2020-05-11 21:19:34 -07:00
const assetDatum = asset.assetData[0];
const url = new URL(assetDatum.path, "http://images.neopets.com");
return url.toString();
},
item: async ({ id }, _, { db }) => {
// TODO: If this becomes a popular request, we'll definitely need to
// loaderize this! I'm cheating for now because it's just Support, one at
// a time.
const [rows] = await db.query(
`
SELECT parent_id FROM parents_swf_assets
WHERE swf_asset_id = ? AND parent_type = "Item" LIMIT 1;
`,
[id]
);
if (rows.length === 0) {
return null;
}
return { id: String(rows[0].parent_id) };
},
2020-04-23 01:08:00 -07:00
},
Zone: {
depth: async ({ id }, _, { zoneLoader }) => {
// TODO: Should we extend this loader-in-field pattern elsewhere? I like
// that we avoid the fetch in cases where we only want the zone ID,
// but it adds complexity 🤔
const zone = await zoneLoader.load(id);
return zone.depth;
},
label: async ({ id }, _, { zoneTranslationLoader }) => {
const zoneTranslation = await zoneTranslationLoader.load(id);
return zoneTranslation.label;
},
2020-04-22 11:51:36 -07:00
},
2020-04-25 03:42:05 -07:00
Color: {
2020-06-24 19:05:07 -07:00
name: async ({ id }, _, { colorTranslationLoader }) => {
const colorTranslation = await colorTranslationLoader.load(id);
2020-04-25 04:33:05 -07:00
return capitalize(colorTranslation.name);
2020-04-25 03:42:05 -07:00
},
2020-07-31 22:11:32 -07:00
isStandard: async ({ id }, _, { colorLoader }) => {
const color = await colorLoader.load(id);
return color.standard ? true : false;
},
2020-04-25 03:42:05 -07:00
},
Species: {
2020-06-24 19:05:07 -07:00
name: async ({ id }, _, { speciesTranslationLoader }) => {
const speciesTranslation = await speciesTranslationLoader.load(id);
2020-04-25 04:33:05 -07:00
return capitalize(speciesTranslation.name);
2020-04-25 03:42:05 -07:00
},
},
2020-06-24 19:05:07 -07:00
Outfit: {
name: async ({ id }, _, { outfitLoader }) => {
const outfit = await outfitLoader.load(id);
return outfit.name;
},
petAppearance: async ({ id }, _, { outfitLoader }) => {
const outfit = await outfitLoader.load(id);
return { id: outfit.petStateId };
2020-06-24 19:05:07 -07:00
},
wornItems: async ({ id }, _, { itemOutfitRelationshipsLoader }) => {
const relationships = await itemOutfitRelationshipsLoader.load(id);
return relationships
.filter((oir) => oir.isWorn)
.map((oir) => ({ id: oir.itemId }));
},
closetedItems: async ({ id }, _, { itemOutfitRelationshipsLoader }) => {
const relationships = await itemOutfitRelationshipsLoader.load(id);
return relationships
.filter((oir) => !oir.isWorn)
.map((oir) => ({ id: oir.itemId }));
},
},
2020-04-22 11:51:36 -07:00
Query: {
2020-07-31 22:11:32 -07:00
allColors: async (_, { ids }, { colorLoader }) => {
const allColors = await colorLoader.loadAll();
2020-04-25 03:42:05 -07:00
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;
},
item: (_, { id }) => ({ id }),
items: (_, { ids }) => {
return ids.map((id) => ({ id }));
2020-04-24 21:17:03 -07:00
},
itemSearch: async (_, { query }, { itemSearchLoader }) => {
2020-05-31 15:52:54 -07:00
const items = await itemSearchLoader.load(query.trim());
2020-04-25 01:55:48 -07:00
return { query, items };
},
itemSearchToFit: async (
_,
2020-04-25 01:55:48 -07:00
{ query, speciesId, colorId, offset, limit },
2020-06-24 19:05:07 -07:00
{ petTypeBySpeciesAndColorLoader, itemSearchToFitLoader }
) => {
2020-06-24 19:05:07 -07:00
const petType = await petTypeBySpeciesAndColorLoader.load({
speciesId,
colorId,
});
const { bodyId } = petType;
2020-04-25 01:55:48 -07:00
const items = await itemSearchToFitLoader.load({
2020-05-31 15:52:54 -07:00
query: query.trim(),
2020-04-25 01:55:48 -07:00
bodyId,
offset,
limit,
});
return { query, items };
2020-04-23 01:08:00 -07:00
},
petAppearanceById: (_, { id }) => ({ id }),
petAppearance: async (
_,
2020-05-23 12:47:06 -07:00
{ speciesId, colorId, pose },
2020-06-24 19:05:07 -07:00
{ petTypeBySpeciesAndColorLoader, petStatesForPetTypeLoader }
) => {
2020-06-24 19:05:07 -07:00
const petType = await petTypeBySpeciesAndColorLoader.load({
speciesId,
colorId,
});
// TODO: We could query for this more directly, instead of loading all
// appearances 🤔
2020-06-24 19:05:07 -07:00
const petStates = await petStatesForPetTypeLoader.load(petType.id);
const petState = petStates.find((ps) => getPoseFromPetState(ps) === pose);
if (!petState) {
return null;
}
return { id: petState.id };
},
petAppearances: async (
_,
{ speciesId, colorId },
2020-06-24 19:05:07 -07:00
{ petTypeBySpeciesAndColorLoader, petStatesForPetTypeLoader }
) => {
2020-06-24 19:05:07 -07:00
const petType = await petTypeBySpeciesAndColorLoader.load({
speciesId,
colorId,
});
2020-06-24 19:05:07 -07:00
const petStates = await petStatesForPetTypeLoader.load(petType.id);
return petStates.map((petState) => ({ id: petState.id }));
},
2020-06-24 19:05:07 -07:00
outfit: (_, { id }) => ({ id }),
petOnNeopetsDotCom: async (_, { petName }) => {
const [petMetaData, customPetData] = await Promise.all([
neopets.loadPetMetaData(petName),
neopets.loadCustomPetData(petName),
]);
const outfit = {
2020-06-24 19:05:07 -07:00
// TODO: This isn't a fully-working Outfit object. It works for the
// client as currently implemented, but we'll probably want to
// move the client and this onto our more generic fields!
species: { id: customPetData.custom_pet.species_id },
color: { id: customPetData.custom_pet.color_id },
pose: getPoseFromPetData(petMetaData, customPetData),
items: Object.values(customPetData.object_info_registry).map((o) => ({
id: o.obj_info_id,
name: o.name,
description: o.description,
thumbnailUrl: o.thumbnail_url,
rarityIndex: o.rarity_index,
})),
};
return outfit;
},
2020-04-22 11:51:36 -07:00
},
Mutation: {
setManualSpecialColor: async (
_,
{ itemId, colorId, supportSecret },
{ itemLoader, itemTranslationLoader, colorTranslationLoader, db }
) => {
if (supportSecret !== process.env["SUPPORT_SECRET"]) {
throw new Error(`Support secret is incorrect. Try setting up again?`);
}
const oldItem = await itemLoader.load(itemId);
const [
result,
] = await db.execute(
`UPDATE items SET manual_special_color_id = ? WHERE id = ? LIMIT 1`,
[colorId, itemId]
);
if (result.affectedRows !== 1) {
throw new Error(
`Expected to affect 1 item, but affected ${result.affectedRows}`
);
}
itemLoader.clear(itemId); // we changed the item, so clear it from cache
if (process.env["SUPPORT_TOOLS_DISCORD_WEBHOOK_URL"]) {
try {
const [
itemTranslation,
oldColorTranslation,
newColorTranslation,
] = await Promise.all([
itemTranslationLoader.load(itemId),
oldItem.manualSpecialColorId
? colorTranslationLoader.load(oldItem.manualSpecialColorId)
: Promise.resolve(null),
colorId
? colorTranslationLoader.load(colorId)
: Promise.resolve(null),
]);
const oldColorName = oldColorTranslation
? capitalize(oldColorTranslation.name)
: "Auto-detect";
const newColorName = newColorTranslation
? capitalize(newColorTranslation.name)
: "Auto-detect";
await logToDiscord({
embeds: [
{
title: `🛠 ${itemTranslation.name}`,
thumbnail: {
url: oldItem.thumbnailUrl,
height: 80,
width: 80,
},
fields: [
{
name: "Special color",
value: `${oldColorName} → **${newColorName}**`,
},
],
timestamp: new Date().toISOString(),
url: `https://impress.openneo.net/items/${oldItem.id}`,
},
],
});
} catch (e) {
console.error("Error sending Discord support log", e);
}
} else {
console.warn("No Discord support webhook provided, skipping");
}
return { id: itemId };
},
setItemExplicitlyBodySpecific: async (
_,
{ itemId, explicitlyBodySpecific, supportSecret },
{ itemLoader, itemTranslationLoader, db }
) => {
if (supportSecret !== process.env["SUPPORT_SECRET"]) {
throw new Error(`Support secret is incorrect. Try setting up again?`);
}
const oldItem = await itemLoader.load(itemId);
const [
result,
] = await db.execute(
`UPDATE items SET explicitly_body_specific = ? WHERE id = ? LIMIT 1`,
[explicitlyBodySpecific ? 1 : 0, itemId]
);
if (result.affectedRows !== 1) {
throw new Error(
`Expected to affect 1 item, but affected ${result.affectedRows}`
);
}
itemLoader.clear(itemId); // we changed the item, so clear it from cache
if (process.env["SUPPORT_TOOLS_DISCORD_WEBHOOK_URL"]) {
try {
const itemTranslation = await itemTranslationLoader.load(itemId);
const oldRuleName = oldItem.explicitlyBodySpecific
? "Body specific"
: "Auto-detect";
const newRuleName = explicitlyBodySpecific
? "Body specific"
: "Auto-detect";
await logToDiscord({
embeds: [
{
title: `🛠 ${itemTranslation.name}`,
thumbnail: {
url: oldItem.thumbnailUrl,
height: 80,
width: 80,
},
fields: [
{
name: "Pet compatibility rule",
value: `${oldRuleName} → **${newRuleName}**`,
},
],
timestamp: new Date().toISOString(),
url: `https://impress.openneo.net/items/${oldItem.id}`,
},
],
});
} catch (e) {
console.error("Error sending Discord support log", e);
}
} else {
console.warn("No Discord support webhook provided, skipping");
}
return { id: itemId };
},
setLayerBodyId: async (
_,
{ layerId, bodyId, supportSecret },
{
itemLoader,
itemTranslationLoader,
swfAssetLoader,
zoneTranslationLoader,
db,
}
) => {
if (supportSecret !== process.env["SUPPORT_SECRET"]) {
throw new Error(`Support secret is incorrect. Try setting up again?`);
}
const oldSwfAsset = await swfAssetLoader.load(layerId);
const [
result,
] = await db.execute(
`UPDATE swf_assets SET body_id = ? WHERE id = ? LIMIT 1`,
[bodyId, layerId]
);
if (result.affectedRows !== 1) {
throw new Error(
`Expected to affect 1 layer, but affected ${result.affectedRows}`
);
}
swfAssetLoader.clear(layerId); // we changed it, so clear it from cache
if (process.env["SUPPORT_TOOLS_DISCORD_WEBHOOK_URL"]) {
try {
const itemId = await db
.execute(
`SELECT parent_id FROM parents_swf_assets
WHERE swf_asset_id = ? AND parent_type = "Item" LIMIT 1;`,
[layerId]
)
.then(([rows]) => normalizeRow(rows[0]).parentId);
const [
item,
itemTranslation,
zoneTranslation,
oldBodyName,
newBodyName,
] = await Promise.all([
itemLoader.load(itemId),
itemTranslationLoader.load(itemId),
zoneTranslationLoader.load(oldSwfAsset.zoneId),
loadBodyName(oldSwfAsset.bodyId, db),
loadBodyName(bodyId, db),
]);
await logToDiscord({
embeds: [
{
title: `🛠 ${itemTranslation.name}`,
thumbnail: {
url: item.thumbnailUrl,
height: 80,
width: 80,
},
fields: [
{
name:
`Layer ${layerId} (${zoneTranslation.label}): ` +
`Pet compatibility`,
value: `${oldBodyName} → **${newBodyName}**`,
},
],
timestamp: new Date().toISOString(),
url: `https://impress.openneo.net/items/${itemId}`,
},
],
});
} catch (e) {
console.error("Error sending Discord support log", e);
}
} else {
console.warn("No Discord support webhook provided, skipping");
}
return { id: layerId };
},
removeLayerFromItem: async (
_,
{ layerId, itemId, supportSecret },
{
itemLoader,
itemTranslationLoader,
swfAssetLoader,
zoneTranslationLoader,
db,
}
) => {
if (supportSecret !== process.env["SUPPORT_SECRET"]) {
throw new Error(`Support secret is incorrect. Try setting up again?`);
}
const oldSwfAsset = await swfAssetLoader.load(layerId);
const [result] = await db.execute(
`DELETE FROM parents_swf_assets ` +
`WHERE swf_asset_id = ? AND parent_type = "Item" AND parent_id = ? ` +
`LIMIT 1`,
[layerId, itemId]
);
if (result.affectedRows !== 1) {
throw new Error(
`Expected to affect 1 layer, but affected ${result.affectedRows}`
);
}
swfAssetLoader.clear(layerId); // we changed it, so clear it from cache
if (process.env["SUPPORT_TOOLS_DISCORD_WEBHOOK_URL"]) {
try {
const [
item,
itemTranslation,
zoneTranslation,
bodyName,
] = await Promise.all([
itemLoader.load(itemId),
itemTranslationLoader.load(itemId),
zoneTranslationLoader.load(oldSwfAsset.zoneId),
loadBodyName(oldSwfAsset.bodyId, db),
]);
await logToDiscord({
embeds: [
{
title: `🛠 ${itemTranslation.name}`,
thumbnail: {
url: item.thumbnailUrl,
height: 80,
width: 80,
},
fields: [
{
name: `Layer ${layerId} (${zoneTranslation.label})`,
value: `❌ Removed from ${bodyName}`,
},
],
timestamp: new Date().toISOString(),
url: `https://impress.openneo.net/items/${itemId}`,
},
],
});
} catch (e) {
console.error("Error sending Discord support log", e);
}
} else {
console.warn("No Discord support webhook provided, skipping");
}
return { layer: { id: layerId }, item: { id: itemId } };
},
setPetAppearancePose: async (
_,
{ appearanceId, pose, supportSecret },
{
colorTranslationLoader,
speciesTranslationLoader,
petStateLoader,
petTypeLoader,
db,
}
) => {
if (supportSecret !== process.env["SUPPORT_SECRET"]) {
throw new Error(`Support secret is incorrect. Try setting up again?`);
}
const oldPetState = await petStateLoader.load(appearanceId);
const { moodId, female, unconverted } = getPetStateFieldsFromPose(pose);
const [result] = await db.execute(
`UPDATE pet_states SET mood_id = ?, female = ?, unconverted = ?
WHERE id = ? LIMIT 1`,
[moodId, female, unconverted, appearanceId]
);
if (result.affectedRows !== 1) {
throw new Error(
`Expected to affect 1 layer, but affected ${result.affectedRows}`
);
}
// we changed it, so clear it from cache
petStateLoader.clear(appearanceId);
if (process.env["SUPPORT_TOOLS_DISCORD_WEBHOOK_URL"]) {
try {
const petType = await petTypeLoader.load(oldPetState.petTypeId);
const [colorTranslation, speciesTranslation] = await Promise.all([
colorTranslationLoader.load(petType.colorId),
speciesTranslationLoader.load(petType.speciesId),
]);
const oldPose = getPoseFromPetState(oldPetState);
const colorName = capitalize(colorTranslation.name);
const speciesName = capitalize(speciesTranslation.name);
await logToDiscord({
embeds: [
{
title: `🛠 ${colorName} ${speciesName}`,
thumbnail: {
url: `http://pets.neopets.com/cp/${
petType.basicImageHash || petType.imageHash
}/1/6.png`,
height: 150,
width: 150,
},
fields: [
{
name: `Appearance ${appearanceId}: Pose`,
value: `${getPoseName(oldPose)} → **${getPoseName(pose)}**`,
},
{
name: "As a reminder…",
value: "…the thumbnail might not match!",
},
],
timestamp: new Date().toISOString(),
url: `https://impress-2020.openneo.net/outfits/new?species=${petType.speciesId}&color=${petType.colorId}&pose=${pose}&state=${appearanceId}`,
},
],
});
} catch (e) {
console.error("Error sending Discord support log", e);
}
} else {
console.warn("No Discord support webhook provided, skipping");
}
return { id: appearanceId };
},
setPetAppearanceIsGlitched: async (
_,
{ appearanceId, isGlitched, supportSecret },
{
colorTranslationLoader,
speciesTranslationLoader,
petStateLoader,
petTypeLoader,
db,
}
) => {
if (supportSecret !== process.env["SUPPORT_SECRET"]) {
throw new Error(`Support secret is incorrect. Try setting up again?`);
}
const oldPetState = await petStateLoader.load(appearanceId);
const [
result,
] = await db.execute(
`UPDATE pet_states SET glitched = ? WHERE id = ? LIMIT 1`,
[isGlitched, appearanceId]
);
if (result.affectedRows !== 1) {
throw new Error(
`Expected to affect 1 layer, but affected ${result.affectedRows}`
);
}
// we changed it, so clear it from cache
petStateLoader.clear(appearanceId);
if (process.env["SUPPORT_TOOLS_DISCORD_WEBHOOK_URL"]) {
try {
const petType = await petTypeLoader.load(oldPetState.petTypeId);
const [colorTranslation, speciesTranslation] = await Promise.all([
colorTranslationLoader.load(petType.colorId),
speciesTranslationLoader.load(petType.speciesId),
]);
const colorName = capitalize(colorTranslation.name);
const speciesName = capitalize(speciesTranslation.name);
const pose = getPoseFromPetState(oldPetState);
const oldGlitchinessState =
String(oldPetState.glitched) === "1" ? "Glitched" : "Valid";
const newGlitchinessState = isGlitched ? "Glitched" : "Valid";
await logToDiscord({
embeds: [
{
title: `🛠 ${colorName} ${speciesName}`,
thumbnail: {
url: `http://pets.neopets.com/cp/${
petType.basicImageHash || petType.imageHash
}/1/6.png`,
height: 150,
width: 150,
},
fields: [
{
name: `Appearance ${appearanceId}`,
value: `${oldGlitchinessState} → **${newGlitchinessState}**`,
},
],
timestamp: new Date().toISOString(),
url: `https://impress-2020.openneo.net/outfits/new?species=${petType.speciesId}&color=${petType.colorId}&pose=${pose}&state=${appearanceId}`,
},
],
});
} catch (e) {
console.error("Error sending Discord support log", e);
}
} else {
console.warn("No Discord support webhook provided, skipping");
}
return { id: appearanceId };
},
},
2020-04-22 11:51:36 -07:00
};
2020-05-23 11:32:05 -07:00
let lastSvgLogger = null;
const svgLogging = {
requestDidStart() {
return {
willSendResponse({ operationName }) {
const logEntries = lastSvgLogger.entries;
if (logEntries.length === 0) {
return;
}
console.log(`[svgLogger] Operation: ${operationName}`);
const logEntryCounts = {};
for (const logEntry of logEntries) {
logEntryCounts[logEntry] = (logEntryCounts[logEntry] || 0) + 1;
}
const logEntriesSortedByCount = Object.entries(logEntryCounts).sort(
(a, b) => b[1] - a[1]
);
for (const [logEntry, count] of logEntriesSortedByCount) {
console.log(`[svgLogger] - ${logEntry}: ${count}`);
}
},
};
},
};
const schema = makeExecutableSchema({ typeDefs, resolvers });
2020-08-17 01:27:05 -07:00
const plugins = [svgLogging];
if (process.env["NODE_ENV"] !== "test") {
addBeelineToSchema(schema);
plugins.push(beelinePlugin);
}
const config = {
schema,
2020-04-22 11:51:36 -07:00
context: async () => {
const db = await connectToDb();
2020-05-23 11:32:05 -07:00
const svgLogger = {
entries: [],
log(entry) {
this.entries.push(entry);
},
};
lastSvgLogger = svgLogger;
return {
2020-05-23 11:32:05 -07:00
svgLogger,
db,
...buildLoaders(db),
};
2020-04-22 11:51:36 -07:00
},
2020-04-23 01:09:17 -07:00
2020-08-17 01:27:05 -07:00
plugins,
2020-05-23 11:32:05 -07:00
2020-04-23 01:09:17 -07:00
// Enable Playground in production :)
introspection: true,
playground: {
endpoint: "/api/graphql",
},
};
2020-04-22 11:51:36 -07:00
if (require.main === module) {
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}`);
});
}
module.exports = { config };