Support alt styles in outfit thumbnails

Oops, before this change, outfits with alt styles would still show the
outfit as if no alt style were applied!

Now, we have the `Outfit` GraphQL type be internally aware of alt
styles, and set its `petAppearance` and `body` and `itemAppearances`
fields accordingly. No change was required to the actual
`/api/outfitImage` endpoint, once the GraphQL started returning the
right thing!

…because of that, I'm honestly kinda surprised that there's no obvious
issues arising with the Impress 2020 outfit interface itself? But it
seems to be correctly just, not showing alt styles at all, in the way I
intended because I never added support to it. So, okay, cool!
This commit is contained in:
Emi Matchu 2024-02-08 10:51:52 -08:00
parent dc954d7c3c
commit 98eb14853c
3 changed files with 95 additions and 30 deletions

View file

@ -1039,6 +1039,29 @@ const buildPetSwfAssetLoader = (db, loaders) =>
); );
}); });
const buildAltStyleSwfAssetLoader = (db, loaders) =>
new DataLoader(async (altStyleIds) => {
const qs = altStyleIds.map((_) => "?").join(",");
const [rows] = await db.execute(
`SELECT sa.*, rel.parent_id FROM swf_assets sa
INNER JOIN parents_swf_assets rel ON
rel.parent_type = "AltStyle" AND
rel.swf_asset_id = sa.id
WHERE rel.parent_id IN (${qs})`,
altStyleIds,
);
const entities = rows.map(normalizeRow);
for (const swfAsset of entities) {
loaders.swfAssetLoader.prime(swfAsset.id, swfAsset);
}
return altStyleIds.map((altStyleId) =>
entities.filter((e) => e.parentId === altStyleId),
);
});
const buildNeopetsConnectionLoader = (db) => const buildNeopetsConnectionLoader = (db) =>
new DataLoader(async (ids) => { new DataLoader(async (ids) => {
const qs = ids.map((_) => "?").join(", "); const qs = ids.map((_) => "?").join(", ");
@ -1490,6 +1513,7 @@ function buildLoaders(db) {
loaders.swfAssetByRemoteIdLoader = buildSwfAssetByRemoteIdLoader(db); loaders.swfAssetByRemoteIdLoader = buildSwfAssetByRemoteIdLoader(db);
loaders.itemSwfAssetLoader = buildItemSwfAssetLoader(db, loaders); loaders.itemSwfAssetLoader = buildItemSwfAssetLoader(db, loaders);
loaders.petSwfAssetLoader = buildPetSwfAssetLoader(db, loaders); loaders.petSwfAssetLoader = buildPetSwfAssetLoader(db, loaders);
loaders.altStyleSwfAssetLoader = buildAltStyleSwfAssetLoader(db, loaders);
loaders.neopetsConnectionLoader = buildNeopetsConnectionLoader(db); loaders.neopetsConnectionLoader = buildNeopetsConnectionLoader(db);
loaders.outfitLoader = buildOutfitLoader(db); loaders.outfitLoader = buildOutfitLoader(db);
loaders.itemOutfitRelationshipsLoader = loaders.itemOutfitRelationshipsLoader =

View file

@ -62,9 +62,9 @@ const resolvers = {
const outfit = await outfitLoader.load(id); const outfit = await outfitLoader.load(id);
return outfit.name; return outfit.name;
}, },
petAppearance: async ({ id }, _, { outfitLoader }) => { petAppearance: async ({ id }, _, { outfitLoader, altStyleLoader }) => {
const outfit = await outfitLoader.load(id); const outfit = await outfitLoader.load(id);
return { id: outfit.petStateId }; return { id: outfit.petStateId, altStyleId: outfit.altStyleId };
}, },
itemAppearances: async ( itemAppearances: async (
{ id }, { id },
@ -73,15 +73,26 @@ const resolvers = {
outfitLoader, outfitLoader,
petStateLoader, petStateLoader,
petTypeLoader, petTypeLoader,
altStyleLoader,
itemOutfitRelationshipsLoader, itemOutfitRelationshipsLoader,
} },
) => { ) => {
const [petType, relationships] = await Promise.all([ const relationshipsPromise = itemOutfitRelationshipsLoader.load(id);
outfitLoader
.load(id) const outfit = await outfitLoader.load(id);
.then((outfit) => petStateLoader.load(outfit.petStateId)) const bodyIdPromise =
.then((petState) => petTypeLoader.load(petState.petTypeId)), outfit.altStyleId != null
itemOutfitRelationshipsLoader.load(id), ? altStyleLoader
.load(outfit.altStyleId)
.then((altStyle) => altStyle.bodyId)
: petStateLoader
.load(outfit.petStateId)
.then((petState) => petTypeLoader.load(petState.petTypeId))
.then((petType) => petType.bodyId);
const [bodyId, relationships] = await Promise.all([
bodyIdPromise,
relationshipsPromise,
]); ]);
const wornItemIds = relationships const wornItemIds = relationships
@ -90,7 +101,7 @@ const resolvers = {
return wornItemIds.map((itemId) => ({ return wornItemIds.map((itemId) => ({
item: { id: itemId }, item: { id: itemId },
bodyId: petType.bodyId, bodyId,
})); }));
}, },
wornItems: async ({ id }, _, { itemOutfitRelationshipsLoader }) => { wornItems: async ({ id }, _, { itemOutfitRelationshipsLoader }) => {
@ -157,11 +168,11 @@ const resolvers = {
outfitLoader, outfitLoader,
petTypeBySpeciesAndColorLoader, petTypeBySpeciesAndColorLoader,
petStatesForPetTypeLoader, petStatesForPetTypeLoader,
} },
) => { ) => {
if (!currentUserId) { if (!currentUserId) {
throw new Error( throw new Error(
"saveOutfit requires login for now. This might change!" "saveOutfit requires login for now. This might change!",
); );
} }
@ -172,7 +183,7 @@ const resolvers = {
} }
if (outfit.userId !== currentUserId) { if (outfit.userId !== currentUserId) {
throw new Error( throw new Error(
`user ${currentUserId} does not own outfit ${outfit.id}` `user ${currentUserId} does not own outfit ${outfit.id}`,
); );
} }
} }
@ -181,7 +192,7 @@ const resolvers = {
// suffixes. // suffixes.
const baseName = (rawName || "Untitled outfit").replace( const baseName = (rawName || "Untitled outfit").replace(
/\s*\([0-9]+\)\s*$/, /\s*\([0-9]+\)\s*$/,
"" "",
); );
const namePlaceholder = baseName.trim().replace(/_%/g, "\\$0") + "%"; const namePlaceholder = baseName.trim().replace(/_%/g, "\\$0") + "%";
@ -192,10 +203,10 @@ const resolvers = {
` `
SELECT name FROM outfits WHERE user_id = ? AND name LIKE ? AND id != ?; SELECT name FROM outfits WHERE user_id = ? AND name LIKE ? AND id != ?;
`, `,
[currentUserId, namePlaceholder, id || "<no-ID-new-outfit>"] [currentUserId, namePlaceholder, id || "<no-ID-new-outfit>"],
); );
const existingOutfitNames = new Set( const existingOutfitNames = new Set(
outfitRows.map(({ name }) => name.trim()) outfitRows.map(({ name }) => name.trim()),
); );
// Then, get the unique name to use for this outfit: try the provided // Then, get the unique name to use for this outfit: try the provided
@ -213,7 +224,7 @@ const resolvers = {
}); });
if (!petType) { if (!petType) {
throw new Error( throw new Error(
`could not find pet type for species=${speciesId}, color=${colorId}` `could not find pet type for species=${speciesId}, color=${colorId}`,
); );
} }
// TODO: We could query for this more directly, instead of loading all // TODO: We could query for this more directly, instead of loading all
@ -222,7 +233,7 @@ const resolvers = {
const petState = petStates.find((ps) => getPoseFromPetState(ps) === pose); const petState = petStates.find((ps) => getPoseFromPetState(ps) === pose);
if (!petState) { if (!petState) {
throw new Error( throw new Error(
`could not find appearance for species=${speciesId}, color=${colorId}, pose=${pose}` `could not find appearance for species=${speciesId}, color=${colorId}, pose=${pose}`,
); );
} }
@ -242,7 +253,7 @@ const resolvers = {
updated_at = CURRENT_TIMESTAMP() updated_at = CURRENT_TIMESTAMP()
WHERE id = ?; WHERE id = ?;
`, `,
[name, petState.id, id] [name, petState.id, id],
) )
: await connection.execute( : await connection.execute(
` `
@ -250,7 +261,7 @@ const resolvers = {
(name, pet_state_id, user_id, created_at, updated_at) (name, pet_state_id, user_id, created_at, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); VALUES (?, ?, ?, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP());
`, `,
[name, petState.id, currentUserId] [name, petState.id, currentUserId],
); );
newOutfitId = id || String(result.insertId); newOutfitId = id || String(result.insertId);
@ -264,14 +275,14 @@ const resolvers = {
if (id) { if (id) {
await connection.execute( await connection.execute(
`DELETE FROM item_outfit_relationships WHERE outfit_id = ?;`, `DELETE FROM item_outfit_relationships WHERE outfit_id = ?;`,
[id] [id],
); );
} }
if (wornItemIds.length > 0 || closetedItemIds.length > 0) { if (wornItemIds.length > 0 || closetedItemIds.length > 0) {
const itemRowPlaceholders = [ const itemRowPlaceholders = [
[...wornItemIds, ...closetedItemIds].map( [...wornItemIds, ...closetedItemIds].map(
(_) => `(?, ?, ?, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP())` (_) => `(?, ?, ?, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP())`,
), ),
].join(", "); ].join(", ");
const itemRowValues = [ const itemRowValues = [
@ -286,7 +297,7 @@ const resolvers = {
(outfit_id, item_id, is_worn, created_at, updated_at) (outfit_id, item_id, is_worn, created_at, updated_at)
VALUES ${itemRowPlaceholders}; VALUES ${itemRowPlaceholders};
`, `,
itemRowValues itemRowValues,
); );
} }
@ -314,7 +325,7 @@ const resolvers = {
} }
if (outfit.userId !== currentUserId) { if (outfit.userId !== currentUserId) {
throw new Error( throw new Error(
`user ${currentUserId} does not own outfit ${outfit.id}` `user ${currentUserId} does not own outfit ${outfit.id}`,
); );
} }

View file

@ -279,13 +279,29 @@ const resolvers = {
const petType = await petTypeLoader.load(petState.petTypeId); const petType = await petTypeLoader.load(petState.petTypeId);
return { id: petType.speciesId }; return { id: petType.speciesId };
}, },
body: async ({ id }, _, { petStateLoader, petTypeLoader }) => { body: async (
{ id, altStyleId },
_,
{ petStateLoader, petTypeLoader, altStyleLoader },
) => {
const petState = await petStateLoader.load(id); const petState = await petStateLoader.load(id);
if (altStyleId != null) {
const altStyle = await altStyleLoader.load(altStyleId);
return { id: altStyle.bodyId };
}
const petType = await petTypeLoader.load(petState.petTypeId); const petType = await petTypeLoader.load(petState.petTypeId);
return { id: petType.bodyId }; return { id: petType.bodyId };
}, },
bodyId: async ({ id }, _, { petStateLoader, petTypeLoader }) => { bodyId: async (
{ id, altStyleId },
_,
{ petStateLoader, petTypeLoader, altStyleLoader },
) => {
const petState = await petStateLoader.load(id); const petState = await petStateLoader.load(id);
if (altStyleId != null) {
const altStyle = await altStyleLoader.load(altStyleId);
return altStyle.bodyId;
}
const petType = await petTypeLoader.load(petState.petTypeId); const petType = await petTypeLoader.load(petState.petTypeId);
return petType.bodyId; return petType.bodyId;
}, },
@ -293,14 +309,28 @@ const resolvers = {
const petState = await petStateLoader.load(id); const petState = await petStateLoader.load(id);
return getPoseFromPetState(petState); return getPoseFromPetState(petState);
}, },
layers: async ({ id }, _, { petSwfAssetLoader }) => { layers: async (
const swfAssets = await petSwfAssetLoader.load(id); { id, altStyleId },
_,
{ petSwfAssetLoader, altStyleSwfAssetLoader },
) => {
const swfAssets =
altStyleId != null
? await altStyleSwfAssetLoader.load(altStyleId)
: await petSwfAssetLoader.load(id);
return swfAssets; return swfAssets;
}, },
restrictedZones: async ({ id }, _, { petSwfAssetLoader }) => { restrictedZones: async (
{ id, altStyleId },
_,
{ petSwfAssetLoader, altStyleSwfAssetLoader },
) => {
// The restricted zones are defined on the layers. Load them and aggegate // The restricted zones are defined on the layers. Load them and aggegate
// the zones, then uniquify and sort them for ease of use. // the zones, then uniquify and sort them for ease of use.
const swfAssets = await petSwfAssetLoader.load(id); const swfAssets =
altStyleId != null
? await altStyleSwfAssetLoader.load(altStyleId)
: await petSwfAssetLoader.load(id);
let restrictedZoneIds = swfAssets let restrictedZoneIds = swfAssets
.map((sa) => getRestrictedZoneIds(sa.zonesRestrict)) .map((sa) => getRestrictedZoneIds(sa.zonesRestrict))
.flat(); .flat();