- {questItemImage ?

:
}
-
-
Quest
-
-
- {message && (
-
- )}
- {collectedText && (
-
- )}
+
+
+ {message &&
{message}
}
+ {collectedText &&
{collectedText}
}
+
-
-
+
);
};
diff --git a/package-lock.json b/package-lock.json
index 1690db4..d064eee 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,7 +14,7 @@
],
"dependencies": {
"@googleapis/sheets": "^7.0.0",
- "@rtsdk/topia": "^0.15.8",
+ "@rtsdk/topia": "^0.19.4",
"axios": "^1.6.8",
"concurrently": "^8.2.2",
"typescript": "^5.4.3",
@@ -1035,9 +1035,9 @@
]
},
"node_modules/@rtsdk/topia": {
- "version": "0.15.8",
- "resolved": "https://registry.npmjs.org/@rtsdk/topia/-/topia-0.15.8.tgz",
- "integrity": "sha512-T0+pZxqMn6OcDzNIGk50B3lgRWoDBOXSmwShOepNGUyM9t7Yuu7NAMsYHKYEUfXN7X948bW5CetfwWk9Cd2K4w=="
+ "version": "0.19.4",
+ "resolved": "https://registry.npmjs.org/@rtsdk/topia/-/topia-0.19.4.tgz",
+ "integrity": "sha512-kpgWODTaUHQwyDn56rAtDZP6Xg7ug9Z5qDHTR8bzmFOnNFFeFuYrYUc2o8MGApstRVKnAYJaGLXcr4haVZqVaQ=="
},
"node_modules/@sdk-quest/client": {
"resolved": "client",
diff --git a/package.json b/package.json
index 1f8586c..a82a0ea 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
},
"dependencies": {
"@googleapis/sheets": "^7.0.0",
- "@rtsdk/topia": "^0.15.8",
+ "@rtsdk/topia": "^0.19.4",
"axios": "^1.6.8",
"concurrently": "^8.2.2",
"typescript": "^5.4.3",
diff --git a/server/controllers/droppedAssets/handleDropQuestItem.ts b/server/controllers/droppedAssets/handleDropQuestItem.ts
index bcfe9d2..ff57825 100644
--- a/server/controllers/droppedAssets/handleDropQuestItem.ts
+++ b/server/controllers/droppedAssets/handleDropQuestItem.ts
@@ -16,7 +16,11 @@ export const handleDropQuestItem = async (req: Request, res: Response) => {
const { interactivePublicKey, urlSlug } = credentials;
const sceneDropId = credentials.sceneDropId || credentials.assetId;
- const { dataObject, world } = await getWorldDetails(credentials, true);
+ const getWorldDetailsResponse = await getWorldDetails(credentials, true);
+ if (getWorldDetailsResponse instanceof Error) throw getWorldDetailsResponse;
+
+ const { dataObject, world } = getWorldDetailsResponse;
+
const { questItemImage } = dataObject as WorldDataObjectType;
if (!questItemImage) throw "questItemImage is required";
diff --git a/server/controllers/droppedAssets/handleGetQuestDetails.ts b/server/controllers/droppedAssets/handleGetQuestDetails.ts
index 6bd2d5c..891aba0 100644
--- a/server/controllers/droppedAssets/handleGetQuestDetails.ts
+++ b/server/controllers/droppedAssets/handleGetQuestDetails.ts
@@ -1,17 +1,27 @@
import { Request, Response } from "express";
-import { errorHandler, getCredentials, getVisitor, getWorldDetails } from "../../utils/index.js";
+import { errorHandler, getBadges, getCredentials, getVisitor, getWorldDetails } from "../../utils/index.js";
export const handleGetQuestDetails = async (req: Request, res: Response) => {
try {
const credentials = getCredentials(req.query);
- const { dataObject } = await getWorldDetails(credentials, false);
+ const getWorldDetailsResponse = await getWorldDetails(credentials, false);
+ if (getWorldDetailsResponse instanceof Error) throw getWorldDetailsResponse;
- const { visitor } = await getVisitor(credentials, credentials.assetId);
+ const { dataObject } = getWorldDetailsResponse;
+
+ const getVisitorResponse = await getVisitor(credentials, credentials.assetId);
+ if (getVisitorResponse instanceof Error) throw getVisitorResponse;
+
+ const { visitor, visitorInventory } = getVisitorResponse;
+
+ const badges = await getBadges(credentials);
return res.json({
questDetails: dataObject,
visitor: { isAdmin: visitor.isAdmin, profileId: credentials.profileId },
+ visitorInventory,
+ badges,
});
} catch (error) {
return errorHandler({
diff --git a/server/controllers/droppedAssets/handleGetQuestItems.ts b/server/controllers/droppedAssets/handleGetQuestItems.ts
index 133ea41..b023457 100644
--- a/server/controllers/droppedAssets/handleGetQuestItems.ts
+++ b/server/controllers/droppedAssets/handleGetQuestItems.ts
@@ -5,7 +5,10 @@ export const handleGetQuestItems = async (req: Request, res: Response) => {
try {
const credentials = getCredentials(req.query);
- const droppedAssets = await getQuestItems(credentials);
+ const getQuestItemsResponse = await getQuestItems(credentials);
+ if (getQuestItemsResponse instanceof Error) throw getQuestItemsResponse;
+
+ const droppedAssets = getQuestItemsResponse;
return res.json({ droppedAssets });
} catch (error) {
diff --git a/server/controllers/droppedAssets/handleQuestItemClicked.ts b/server/controllers/droppedAssets/handleQuestItemClicked.ts
index 2e187e5..b1ca082 100644
--- a/server/controllers/droppedAssets/handleQuestItemClicked.ts
+++ b/server/controllers/droppedAssets/handleQuestItemClicked.ts
@@ -11,6 +11,8 @@ import {
getRandomCoordinates,
getVisitor,
getWorldDetails,
+ awardBadge,
+ getBadges,
} from "../../utils/index.js";
import { AxiosError } from "axios";
@@ -25,27 +27,55 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => {
const currentDate = new Date(localDateString);
currentDate.setHours(0, 0, 0, 0);
- const analytics = [];
+ const analytics = [],
+ promises = [];
const questItem = await DroppedAsset.get(assetId, urlSlug, { credentials });
- const { dataObject: worldDataObject, world } = await getWorldDetails(credentials, true);
+ const getWorldDetailsResponse = await getWorldDetails(credentials, true);
+ if (getWorldDetailsResponse instanceof Error) throw getWorldDetailsResponse;
+
+ const { dataObject: worldDataObject, world } = getWorldDetailsResponse;
+
let { keyAssetId, numberAllowedToCollect } = worldDataObject as WorldDataObjectType;
if (typeof numberAllowedToCollect === "string") numberAllowedToCollect = parseInt(numberAllowedToCollect);
- const { visitor, visitorProgress } = await getVisitor(credentials, keyAssetId);
+ const getVisitorResponse = await getVisitor(credentials, keyAssetId);
+ if (getVisitorResponse instanceof Error) throw getVisitorResponse;
+
+ const { visitor, visitorProgress, visitorInventory } = getVisitorResponse;
let { currentStreak, lastCollectedDate, longestStreak, totalCollected, totalCollectedToday } = visitorProgress;
+ const badges = await getBadges(credentials);
+
+ // Award First Find badge if visitor collected their first quest item
+ if (totalCollected === 0 || !visitorInventory["First Find"]) {
+ promises.push(
+ awardBadge({ credentials, visitor, visitorInventory, badgeName: "First Find" }).catch((error) =>
+ errorHandler({
+ error,
+ functionName: "handleQuestItemClicked",
+ message: "Error awarding First Find badge",
+ }),
+ ),
+ );
+ }
+
const differenceInDays = getDifferenceInDays(currentDate, new Date(lastCollectedDate));
const hasCollectedToday = differenceInDays === 0;
if (!hasCollectedToday) analytics.push({ analyticName: "starts", profileId, urlSlug, uniqueKey: profileId });
if (hasCollectedToday && totalCollectedToday >= numberAllowedToCollect) {
- return res.json({ addedClick: false, numberAllowedToCollect, questDetails: worldDataObject });
+ return res.json({
+ addedClick: false,
+ numberAllowedToCollect,
+ questDetails: worldDataObject,
+ badges,
+ visitorInventory,
+ });
} else {
- const promises = [];
analytics.push({ analyticName: "itemsCollected" });
// Move the quest item to a new random location
@@ -61,18 +91,66 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => {
}),
);
- world.triggerParticle({ position: questItem.position, name: "lightBlueSmoke_puff" }).catch((error: AxiosError) =>
- errorHandler({
- error,
- functionName: "handleQuestItemClicked",
- message: "Error triggering particle effects",
- }),
+ promises.push(
+ world
+ .triggerParticle({ position: questItem.position, name: "lightBlueSmoke_puff" })
+ .catch((error: AxiosError) =>
+ errorHandler({
+ error,
+ functionName: "handleQuestItemClicked",
+ message: "Error triggering particle effects",
+ }),
+ ),
);
totalCollected = totalCollected + 1;
+
+ // Award Quest Veteran badges if visitor collected [x] quest items
+ let veteranBadgeName;
+ if (totalCollected === 25) veteranBadgeName = "Quest Veteran - Bronze";
+ else if (totalCollected === 50) veteranBadgeName = "Quest Veteran - Silver";
+ else if (totalCollected === 75) veteranBadgeName = "Quest Veteran - Gold";
+ else if (totalCollected === 100) veteranBadgeName = "Quest Veteran - Diamond";
+ if (veteranBadgeName) {
+ promises.push(
+ awardBadge({ credentials, visitor, visitorInventory, badgeName: veteranBadgeName }).catch((error) =>
+ errorHandler({
+ error,
+ functionName: "handleQuestItemClicked",
+ message: `Error awarding ${veteranBadgeName} badge`,
+ }),
+ ),
+ );
+ }
+
if (!hasCollectedToday) {
totalCollectedToday = 1;
- if (differenceInDays === 1) currentStreak = currentStreak + 1;
+ if (differenceInDays === 1) {
+ currentStreak = currentStreak + 1;
+
+ // Award Streak badges if visitor collected their item for 3 or 5 days in a row
+ if (currentStreak === 3) {
+ promises.push(
+ awardBadge({ credentials, visitor, visitorInventory, badgeName: "3-Day Streak" }).catch((error) =>
+ errorHandler({
+ error,
+ functionName: "handleQuestItemClicked",
+ message: "Error awarding 3-Day Streak badge",
+ }),
+ ),
+ );
+ } else if (currentStreak === 5) {
+ promises.push(
+ awardBadge({ credentials, visitor, visitorInventory, badgeName: "5-Day Streak" }).catch((error) =>
+ errorHandler({
+ error,
+ functionName: "handleQuestItemClicked",
+ message: "Error awarding 5-Day Streak badge",
+ }),
+ ),
+ );
+ }
+ }
} else {
totalCollectedToday = totalCollectedToday + 1;
}
@@ -80,35 +158,53 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => {
if (currentStreak > longestStreak) longestStreak = currentStreak;
if (totalCollectedToday === numberAllowedToCollect) {
- visitor.triggerParticle({ duration: 60, name: "redPinkHeart_float" }).catch((error: AxiosError) =>
- errorHandler({
- error,
- functionName: "handleQuestItemClicked",
- message: "Error triggering particle effects",
- }),
+ promises.push(
+ visitor.triggerParticle({ duration: 60, name: "redPinkHeart_float" }).catch((error: AxiosError) =>
+ errorHandler({
+ error,
+ functionName: "handleQuestItemClicked",
+ message: "Error triggering particle effects",
+ }),
+ ),
);
analytics.push({ analyticName: "completions", profileId, urlSlug, uniqueKey: profileId });
- addNewRowToGoogleSheets([
- {
- identityId,
- displayName,
- event: "completions",
- urlSlug,
- },
- ]);
+ promises.push(
+ addNewRowToGoogleSheets([
+ {
+ identityId,
+ displayName,
+ event: "completions",
+ urlSlug,
+ },
+ ]),
+ );
+
+ // Award Inventory Pro badge if visitor has collected all allowed quest items for the day
+ promises.push(
+ awardBadge({ credentials, visitor, visitorInventory, badgeName: "Inventory Pro" }).catch((error) =>
+ errorHandler({
+ error,
+ functionName: "handleQuestItemClicked",
+ message: "Error awarding Inventory Pro badge",
+ }),
+ ),
+ );
}
promises.push(
- visitor.updateDataObject({
- [`${urlSlug}-${sceneDropId}`]: {
- currentStreak,
- lastCollectedDate: currentDate,
- longestStreak,
- totalCollected,
- totalCollectedToday,
+ visitor.updateDataObject(
+ {
+ [`${urlSlug}-${sceneDropId}`]: {
+ currentStreak,
+ lastCollectedDate: currentDate,
+ longestStreak,
+ totalCollected,
+ totalCollectedToday,
+ },
},
- }),
+ {},
+ ),
);
if (totalCollected % 50 === 0) {
@@ -121,36 +217,45 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => {
text = "Congrats! Your detective skills paid off.";
// @ts-ignore
if (grantExpressionResult.data?.statusCode === 200 || grantExpressionResult.status === 200) {
- visitor.triggerParticle({ name: "firework2_gold" }).catch((error: AxiosError) =>
- errorHandler({
- error,
- functionName: "handleQuestItemClicked",
- message: "Error triggering particle effects",
- }),
+ promises.push(
+ visitor.triggerParticle({ name: "firework2_gold" }).catch((error: AxiosError) =>
+ errorHandler({
+ error,
+ functionName: "handleQuestItemClicked",
+ message: "Error triggering particle effects",
+ }),
+ ),
);
analytics.push({ analyticName: `${name}-emoteUnlocked`, urlSlug, uniqueKey: urlSlug });
+ // @ts-ignore
} else if (grantExpressionResult.data?.statusCode === 409 || grantExpressionResult.status === 409) {
title = `Congrats! You collected ${totalCollected} quest items`;
text = "Keep up the solid detective work 🔎";
}
- visitor
- .fireToast({
- groupId: "QuestExpression",
- title,
- text,
- })
- .catch((error: AxiosError) =>
- errorHandler({
- error,
- functionName: "handleQuestItemClicked",
- message: "Error firing toast",
- }),
- );
+ promises.push(
+ visitor
+ .fireToast({
+ groupId: "QuestExpression",
+ title,
+ text,
+ })
+ .catch((error: AxiosError) =>
+ errorHandler({
+ error,
+ functionName: "handleQuestItemClicked",
+ message: "Error firing toast",
+ }),
+ ),
+ );
}
- const keyAsset = await getKeyAsset(credentials, keyAssetId);
+ const getKeyAssetResponse = await getKeyAsset(credentials, keyAssetId);
+ if (getKeyAssetResponse instanceof Error) throw getKeyAssetResponse;
+
+ const keyAsset = getKeyAssetResponse;
+
promises.push(
keyAsset.updateDataObject(
{
@@ -160,13 +265,23 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => {
),
);
- await Promise.all(promises);
+ const results = await Promise.allSettled(promises);
+ results.forEach((result) => {
+ if (result.status === "rejected") console.error(result.reason);
+ });
+
+ const getVisitorResponse = await getVisitor(credentials, keyAssetId);
+ if (getVisitorResponse instanceof Error) throw getVisitorResponse;
+
+ const { visitorInventory: updatedInventory } = getVisitorResponse;
return res.json({
addedClick: true,
numberAllowedToCollect,
totalCollectedToday,
questDetails: worldDataObject,
+ badges,
+ visitorInventory: updatedInventory,
});
}
} catch (error) {
diff --git a/server/controllers/handleGetLeaderboard.ts b/server/controllers/handleGetLeaderboard.ts
index 4c93057..d585fc2 100644
--- a/server/controllers/handleGetLeaderboard.ts
+++ b/server/controllers/handleGetLeaderboard.ts
@@ -9,12 +9,17 @@ export const handleGetLeaderboard = async (req: Request, res: Response) => {
let keyAssetId = credentials.assetId;
if (!isKeyAsset) {
- const { dataObject } = await getWorldDetails(credentials, false);
+ const getWorldDetailsResponse = await getWorldDetails(credentials, false);
+ if (getWorldDetailsResponse instanceof Error) throw getWorldDetailsResponse;
+
+ const { dataObject } = getWorldDetailsResponse;
keyAssetId = dataObject.keyAssetId;
}
- const keyAsset = await getKeyAsset(credentials, keyAssetId);
+ const getKeyAssetResponse = await getKeyAsset(credentials, keyAssetId);
+ if (getKeyAssetResponse instanceof Error) throw getKeyAssetResponse;
+ const keyAsset = getKeyAssetResponse;
const { leaderboard } = (keyAsset.dataObject as KeyAssetDataObjectType) || {};
let formattedLeaderboard = [];
@@ -36,11 +41,8 @@ export const handleGetLeaderboard = async (req: Request, res: Response) => {
formattedLeaderboard.sort((a, b) => b.collected - a.collected);
- const { visitor } = await getVisitor(credentials, keyAssetId);
-
return res.json({
leaderboard: formattedLeaderboard,
- visitor: { isAdmin: visitor.isAdmin, profileId: credentials.profileId },
});
} catch (error) {
return errorHandler({
diff --git a/server/controllers/handleRemoveQuestFromWorld.ts b/server/controllers/handleRemoveQuestFromWorld.ts
index 095d9fe..a16b6a0 100644
--- a/server/controllers/handleRemoveQuestFromWorld.ts
+++ b/server/controllers/handleRemoveQuestFromWorld.ts
@@ -16,11 +16,15 @@ export const handleRemoveQuestFromWorld = async (req: Request, res: Response) =>
const sceneDropId = credentials.sceneDropId || assetId;
// remove all quest items
- const { success } = await removeQuestItems(credentials);
- if (!success) throw "Error removing quest items.";
+ const removeQuestItemsResponse = await removeQuestItems(credentials);
+ if (removeQuestItemsResponse instanceof Error) throw removeQuestItemsResponse;
// remove data from world data object
- const { world } = await getWorldDetails(credentials, false);
+ const getWorldDetailsResponse = await getWorldDetails(credentials, false);
+ if (getWorldDetailsResponse instanceof Error) throw getWorldDetailsResponse;
+
+ const { world } = getWorldDetailsResponse;
+
await world.updateDataObject(
{
[`scenes.${sceneDropId}`]: `Removed from world on ${new Date()}`,
diff --git a/server/controllers/handleUpdateAdminSettings.ts b/server/controllers/handleUpdateAdminSettings.ts
index 1fadb79..cfa0319 100644
--- a/server/controllers/handleUpdateAdminSettings.ts
+++ b/server/controllers/handleUpdateAdminSettings.ts
@@ -9,7 +9,10 @@ export const handleUpdateAdminSettings = async (req: Request, res: Response) =>
if (!questItemImage) throw "questItemImage is required";
- const { world } = await getWorldDetails(credentials, false);
+ const getWorldDetailsResponse = await getWorldDetails(credentials, false);
+ if (getWorldDetailsResponse instanceof Error) throw getWorldDetailsResponse;
+
+ const { world } = getWorldDetailsResponse;
const lockId = `${sceneDropId}-adminUpdates-${new Date(Math.round(new Date().getTime() / 10000) * 10000)}`;
await world.updateDataObject(
@@ -20,7 +23,11 @@ export const handleUpdateAdminSettings = async (req: Request, res: Response) =>
{ lock: { lockId, releaseLock: true } },
);
- const droppedAssets = await getQuestItems(credentials);
+ const getQuestItemsResponse = await getQuestItems(credentials);
+ if (getQuestItemsResponse instanceof Error) throw getQuestItemsResponse;
+
+ const droppedAssets = getQuestItemsResponse;
+
if (Object.keys(droppedAssets).length > 0) {
const promises: any[] = [];
Object.values(droppedAssets).map((droppedAsset: any) => {
@@ -32,7 +39,7 @@ export const handleUpdateAdminSettings = async (req: Request, res: Response) =>
await world.fetchDataObject();
- return res.json({ questDetails: world.dataObject.scenes?.[sceneDropId] });
+ return res.json({ questDetails: world.dataObject?.scenes?.[sceneDropId] });
} catch (error) {
return errorHandler({
error,
diff --git a/server/types/DataObjectTypes.ts b/server/types/DataObjectTypes.ts
index d595e31..c3011b9 100644
--- a/server/types/DataObjectTypes.ts
+++ b/server/types/DataObjectTypes.ts
@@ -11,13 +11,15 @@ export type WorldDataObjectType = {
questItemImage: string;
};
+export type VisitorProgressType = {
+ currentStreak: number;
+ lastCollectedDate: Date;
+ longestStreak: number;
+ totalCollected: number;
+ totalCollectedToday: number;
+};
+
export type UserDataObjectType = {
- [key: string]: {
- // key = `${urlSlug}-${sceneDropId}`
- currentStreak: number;
- lastCollectedDate: Date;
- longestStreak: number;
- totalCollected: number;
- totalCollectedToday: number;
- };
+ // key = `${urlSlug}-${sceneDropId}`
+ [key: string]: VisitorProgressType;
};
diff --git a/server/utils/awardBadge.ts b/server/utils/awardBadge.ts
new file mode 100644
index 0000000..5066363
--- /dev/null
+++ b/server/utils/awardBadge.ts
@@ -0,0 +1,37 @@
+import { Credentials } from "../types";
+import { Ecosystem, standardizeError } from "./index.js";
+
+export const awardBadge = async ({
+ credentials,
+ visitor,
+ visitorInventory,
+ badgeName,
+}: {
+ credentials: Credentials;
+ visitor: any;
+ visitorInventory: any;
+ badgeName: string;
+}) => {
+ try {
+ if (visitorInventory[badgeName]) return { success: true };
+
+ const ecosystem = await Ecosystem.create({ credentials });
+ await ecosystem.fetchInventoryItems();
+
+ const inventoryItem = ecosystem.inventoryItems?.find((item) => item.name === badgeName);
+ if (!inventoryItem) throw new Error(`Inventory item ${badgeName} not found in ecosystem`);
+
+ await visitor.grantInventoryItem(inventoryItem, 1);
+
+ await visitor
+ .fireToast({
+ title: "Badge Awarded",
+ text: `You have earned the ${badgeName} badge!`,
+ })
+ .catch(() => console.error(`Failed to fire toast after awarding the ${badgeName} badge.`));
+
+ return { success: true };
+ } catch (error: any) {
+ return standardizeError(error);
+ }
+};
diff --git a/server/utils/droppedAssets/getKeyAsset.ts b/server/utils/droppedAssets/getKeyAsset.ts
index 6d58e5c..e2fc9da 100644
--- a/server/utils/droppedAssets/getKeyAsset.ts
+++ b/server/utils/droppedAssets/getKeyAsset.ts
@@ -1,7 +1,7 @@
import { DroppedAsset } from "../topiaInit.js";
-import { errorHandler } from "../errorHandler.js";
import { Credentials } from "../../types/Credentials.js";
import { KeyAssetDataObjectType } from "../../types/DataObjectTypes.js";
+import { standardizeError } from "../standardizeError.js";
export const getKeyAsset = async (credentials: Credentials, keyAssetId: string) => {
try {
@@ -27,10 +27,6 @@ export const getKeyAsset = async (credentials: Credentials, keyAssetId: string)
return keyAsset;
} catch (error) {
- return errorHandler({
- error,
- functionName: "getKeyAsset",
- message: "Error getting key asset",
- });
+ return standardizeError(error);
}
};
diff --git a/server/utils/droppedAssets/getQuestItems.ts b/server/utils/droppedAssets/getQuestItems.ts
index 0503415..6f0ab97 100644
--- a/server/utils/droppedAssets/getQuestItems.ts
+++ b/server/utils/droppedAssets/getQuestItems.ts
@@ -1,6 +1,6 @@
import { World } from "../topiaInit.js";
-import { errorHandler } from "../errorHandler.js";
import { Credentials } from "../../types/Credentials.js";
+import { standardizeError } from "../standardizeError.js";
export const getQuestItems = async (credentials: Credentials) => {
try {
@@ -18,10 +18,6 @@ export const getQuestItems = async (credentials: Credentials) => {
return questItems;
} catch (error) {
- return errorHandler({
- error,
- functionName: "handleGetQuestItems",
- message: "Error fetching Quest items",
- });
+ return standardizeError(error);
}
};
diff --git a/server/utils/droppedAssets/removeQuestItems.ts b/server/utils/droppedAssets/removeQuestItems.ts
index 957b393..c92d77f 100644
--- a/server/utils/droppedAssets/removeQuestItems.ts
+++ b/server/utils/droppedAssets/removeQuestItems.ts
@@ -1,11 +1,14 @@
import { World } from "../topiaInit.js";
import { getQuestItems } from "./getQuestItems.js";
-import { errorHandler } from "../errorHandler.js";
import { Credentials } from "../../types/Credentials";
+import { standardizeError } from "../standardizeError.js";
export const removeQuestItems = async (credentials: Credentials) => {
try {
- const droppedAssets = await getQuestItems(credentials);
+ const getQuestItemsResponse = await getQuestItems(credentials);
+ if (getQuestItemsResponse instanceof Error) throw getQuestItemsResponse;
+
+ const droppedAssets: Record
= getQuestItemsResponse;
if (Object.keys(droppedAssets).length > 0) {
const droppedAssetIds = [];
@@ -22,10 +25,6 @@ export const removeQuestItems = async (credentials: Credentials) => {
return { success: true };
} catch (error) {
- return errorHandler({
- error,
- functionName: "removeQuestItems",
- message: "Error removing Quest items",
- });
+ return standardizeError(error);
}
};
diff --git a/server/utils/getBadges.ts b/server/utils/getBadges.ts
new file mode 100644
index 0000000..9e3d270
--- /dev/null
+++ b/server/utils/getBadges.ts
@@ -0,0 +1,34 @@
+import { Credentials } from "../types/Credentials.js";
+import { getCachedInventoryItems } from "./inventoryCache.js";
+import { standardizeError } from "./standardizeError.js";
+
+export const getBadges = async (credentials: Credentials) => {
+ try {
+ const inventoryItems = await getCachedInventoryItems({ credentials });
+
+ const badges: {
+ [name: string]: {
+ id: string;
+ name: string;
+ icon: string;
+ description: string;
+ };
+ } = {};
+
+ for (const item of inventoryItems) {
+ const { id, name, image_path, description, type, status } = item;
+ if (name && type === "BADGE" && status === "ACTIVE") {
+ badges[name] = {
+ id: id,
+ name,
+ icon: image_path || "",
+ description: description || "",
+ };
+ }
+ }
+
+ return badges;
+ } catch (error) {
+ return standardizeError(error);
+ }
+};
diff --git a/server/utils/getDefaultKeyAssetImage.ts b/server/utils/getDefaultKeyAssetImage.ts
index 115e9df..73ad93f 100644
--- a/server/utils/getDefaultKeyAssetImage.ts
+++ b/server/utils/getDefaultKeyAssetImage.ts
@@ -1,4 +1,4 @@
-import { errorHandler } from "./errorHandler.js";
+import { standardizeError } from "./standardizeError.js";
export const getDefaultKeyAssetImage = async (urlSlug: string) => {
try {
@@ -11,10 +11,6 @@ export const getDefaultKeyAssetImage = async (urlSlug: string) => {
return questItemImage;
} catch (error) {
- return errorHandler({
- error,
- functionName: "getDefaultKeyAssetImage",
- message: "Error getting default key asset image",
- });
+ return standardizeError(error);
}
};
diff --git a/server/utils/getRandomCoordinates.ts b/server/utils/getRandomCoordinates.ts
index 9cb45fb..8d402e6 100644
--- a/server/utils/getRandomCoordinates.ts
+++ b/server/utils/getRandomCoordinates.ts
@@ -1,4 +1,4 @@
-export const getRandomCoordinates = (width: number, height: number) => {
+export const getRandomCoordinates = (width: number = 500, height: number = 500) => {
const x = Math.floor(Math.random() * (width / 2 - -width / 2 + 1) + -width / 2);
const y = Math.floor(Math.random() * (height / 2 - -height / 2 + 1) + -height / 2);
return { x, y };
diff --git a/server/utils/index.ts b/server/utils/index.ts
index 478c6a0..a5844e8 100644
--- a/server/utils/index.ts
+++ b/server/utils/index.ts
@@ -2,11 +2,15 @@ export * from "./droppedAssets/index.js";
export * from "./visitors/index.js";
export * from "./world/index.js";
export * from "./addNewRowToGoogleSheets.js";
+export * from "./awardBadge.js";
export * from "./cleanReturnPayload.js";
export * from "./errorHandler.js";
+export * from "./getBadges.js";
export * from "./getBaseURL.js";
export * from "./getCredentials.js";
export * from "./getDefaultKeyAssetImage.js";
export * from "./getDifferenceInDays.js";
export * from "./getRandomCoordinates.js";
+export * from "./inventoryCache.js";
+export * from "./standardizeError.js";
export * from "./topiaInit.js";
diff --git a/server/utils/inventoryCache.ts b/server/utils/inventoryCache.ts
new file mode 100644
index 0000000..b64a919
--- /dev/null
+++ b/server/utils/inventoryCache.ts
@@ -0,0 +1,110 @@
+import { Credentials } from "../types";
+import { Ecosystem } from "./topiaInit.js";
+import { standardizeError } from "./standardizeError.js";
+import { InventoryItemInterface as BaseInventoryItemInterface } from "@rtsdk/topia";
+
+// Extend InventoryItemInterface to include metadata with optional sortOrder
+interface InventoryItemMetadata {
+ sortOrder?: number;
+ [key: string]: any;
+}
+
+interface InventoryItemInterface extends BaseInventoryItemInterface {
+ metadata?: InventoryItemMetadata | null;
+}
+
+interface CachedInventory {
+ items: InventoryItemInterface[];
+ timestamp: number;
+}
+
+// Cache duration: 24 hours in milliseconds
+const CACHE_DURATION_MS = 24 * 60 * 60 * 1000;
+
+// In-memory cache
+let inventoryCache: CachedInventory | null = null;
+
+/**
+ * Get ecosystem inventory items with caching
+ * - Fetches from cache if available and not expired
+ * - Refreshes cache if expired or missing
+ * - Can force refresh by passing forceRefresh: true
+ */
+export const getCachedInventoryItems = async ({
+ credentials,
+ forceRefresh = false,
+}: {
+ credentials: Credentials;
+ forceRefresh?: boolean;
+}): Promise => {
+ try {
+ const now = Date.now();
+
+ // Check if cache is valid and not expired
+ const isCacheValid = inventoryCache !== null && !forceRefresh && now - inventoryCache.timestamp < CACHE_DURATION_MS;
+
+ if (isCacheValid) {
+ return inventoryCache!.items;
+ }
+
+ // Fetch fresh inventory items
+ console.log("Fetching fresh inventory items from ecosystem");
+ const ecosystem = Ecosystem.create({ credentials });
+ await ecosystem.fetchInventoryItems();
+
+ // Update cache
+ inventoryCache = {
+ items: (ecosystem.inventoryItems as InventoryItemInterface[])
+ .map((item) => ({
+ ...item,
+ metadata: {
+ ...(item.metadata || {}),
+ sortOrder: typeof item.metadata?.sortOrder === "number" ? item.metadata.sortOrder : 0,
+ },
+ }))
+ .sort((a, b) => {
+ const aOrder = a.metadata?.sortOrder ?? 0;
+ const bOrder = b.metadata?.sortOrder ?? 0;
+ return aOrder - bOrder;
+ }),
+ timestamp: now,
+ };
+
+ return inventoryCache.items;
+ } catch (error) {
+ // If fetch fails but we have stale cache, return it as fallback
+ if (inventoryCache !== null) {
+ console.warn("Failed to fetch fresh inventory, using stale cache", error);
+ return inventoryCache.items;
+ }
+
+ throw standardizeError(error);
+ }
+};
+
+/**
+ * Clear the inventory cache (useful for testing or forced refresh)
+ */
+export const clearInventoryCache = (): void => {
+ inventoryCache = null;
+ console.log("Inventory cache cleared");
+};
+
+/**
+ * Get cache status for debugging
+ */
+export const getInventoryCacheStatus = (): {
+ isCached: boolean;
+ age?: number;
+ itemCount?: number;
+} => {
+ if (inventoryCache === null) {
+ return { isCached: false };
+ }
+
+ return {
+ isCached: true,
+ age: Date.now() - inventoryCache.timestamp,
+ itemCount: inventoryCache.items.length,
+ };
+};
diff --git a/server/utils/standardizeError.ts b/server/utils/standardizeError.ts
new file mode 100644
index 0000000..8723ffb
--- /dev/null
+++ b/server/utils/standardizeError.ts
@@ -0,0 +1,27 @@
+/**
+ * Creates a standardized error object from various error types
+ * This helps provide consistent error formatting across the application
+ */
+export const standardizeError = (error: unknown): Error => {
+ // If error is already an Error instance, return it directly
+ if (error instanceof Error) {
+ return error;
+ }
+
+ // If error is a string, create a new Error with that message
+ if (typeof error === "string") {
+ return new Error(error);
+ }
+
+ // If error is an object, try to extract useful information
+ if (typeof error === "object" && error !== null) {
+ const message = (error as any).message || JSON.stringify(error);
+ const newError = new Error(message);
+ // Add original error as property for debugging
+ (newError as any).originalError = error;
+ return newError;
+ }
+
+ // Fallback for other error types
+ return new Error(`Unknown error: ${String(error)}`);
+};
diff --git a/server/utils/topiaInit.ts b/server/utils/topiaInit.ts
index 9ff16db..9341d33 100644
--- a/server/utils/topiaInit.ts
+++ b/server/utils/topiaInit.ts
@@ -1,7 +1,15 @@
import dotenv from "dotenv";
dotenv.config({ path: "../.env" });
-import { Topia, AssetFactory, DroppedAssetFactory, UserFactory, VisitorFactory, WorldFactory } from "@rtsdk/topia";
+import {
+ Topia,
+ AssetFactory,
+ DroppedAssetFactory,
+ EcosystemFactory,
+ UserFactory,
+ VisitorFactory,
+ WorldFactory,
+} from "@rtsdk/topia";
const config = {
apiDomain: process.env.INSTANCE_DOMAIN || "api.topia.io",
@@ -14,8 +22,9 @@ const myTopiaInstance = new Topia(config);
const Asset = new AssetFactory(myTopiaInstance);
const DroppedAsset = new DroppedAssetFactory(myTopiaInstance);
+const Ecosystem = new EcosystemFactory(myTopiaInstance);
const User = new UserFactory(myTopiaInstance);
const Visitor = new VisitorFactory(myTopiaInstance);
const World = new WorldFactory(myTopiaInstance);
-export { Asset, DroppedAsset, myTopiaInstance, User, Visitor, World };
+export { Asset, DroppedAsset, Ecosystem, myTopiaInstance, User, Visitor, World };
diff --git a/server/utils/visitors/getVisitor.ts b/server/utils/visitors/getVisitor.ts
index 341f3d2..cace78b 100644
--- a/server/utils/visitors/getVisitor.ts
+++ b/server/utils/visitors/getVisitor.ts
@@ -1,10 +1,20 @@
import { VisitorInterface } from "@rtsdk/topia";
import { Visitor } from "../topiaInit.js";
-import { errorHandler } from "../errorHandler.js";
import { Credentials } from "../../types/Credentials.js";
-import { UserDataObjectType } from "../../types/DataObjectTypes.js";
+import { UserDataObjectType, VisitorProgressType } from "../../types/DataObjectTypes.js";
+import { standardizeError } from "../standardizeError.js";
-export const getVisitor = async (credentials: Credentials, keyAssetId: string) => {
+export const getVisitor = async (
+ credentials: Credentials,
+ keyAssetId: string,
+): Promise<
+ | {
+ visitor: VisitorInterface;
+ visitorProgress: VisitorProgressType;
+ visitorInventory: { [key: string]: { id: string; icon: string; name: string } };
+ }
+ | Error
+> => {
try {
const { urlSlug, visitorId } = credentials;
const sceneDropId = credentials.sceneDropId || keyAssetId;
@@ -40,12 +50,23 @@ export const getVisitor = async (credentials: Credentials, keyAssetId: string) =
);
}
- return { visitor, visitorProgress };
+ await visitor.fetchInventoryItems();
+ let visitorInventory: { [key: string]: { id: string; icon: string; name: string } } = {};
+
+ for (const item of visitor.inventoryItems) {
+ const { id, name = "", image_url, status, type } = item;
+
+ if (status === "ACTIVE" && type === "BADGE") {
+ visitorInventory[name] = {
+ id,
+ icon: image_url,
+ name,
+ };
+ }
+ }
+
+ return { visitor, visitorProgress, visitorInventory };
} catch (error) {
- return errorHandler({
- error,
- functionName: "getVisitor",
- message: "Error getting visitor",
- });
+ return standardizeError(error);
}
};
diff --git a/server/utils/world/getWorldDetails.ts b/server/utils/world/getWorldDetails.ts
index 1dd3780..8884161 100644
--- a/server/utils/world/getWorldDetails.ts
+++ b/server/utils/world/getWorldDetails.ts
@@ -1,7 +1,8 @@
import { World } from "../topiaInit.js";
-import { errorHandler } from "../errorHandler.js";
import { initializeWorldDataObject } from "./initializeWorldDataObject.js";
import { Credentials, WorldDataObjectType } from "../../types/index.js";
+import { standardizeError } from "../standardizeError.js";
+import { WorldInterface } from "@rtsdk/topia";
type WorldDataObject = {
scenes: {
@@ -9,7 +10,14 @@ type WorldDataObject = {
};
};
-export const getWorldDetails = async (credentials: Credentials, getDetails: boolean = true) => {
+interface WorldType extends WorldInterface {
+ dataObject: WorldDataObject;
+}
+
+export const getWorldDetails = async (
+ credentials: Credentials,
+ getDetails: boolean = true,
+): Promise<{ dataObject: WorldDataObjectType; world: WorldType } | Error> => {
try {
const { assetId, urlSlug } = credentials;
const sceneDropId = credentials.sceneDropId || assetId;
@@ -20,10 +28,12 @@ export const getWorldDetails = async (credentials: Credentials, getDetails: bool
await initializeWorldDataObject({ credentials, world });
+ // Ensure world.dataObject is defined and of correct type
+ if (!world.dataObject) world.dataObject = { scenes: {} };
const dataObject = world.dataObject as WorldDataObject;
- return { dataObject: dataObject.scenes?.[sceneDropId], world };
+ return { dataObject: dataObject.scenes?.[sceneDropId], world: world as WorldType };
} catch (error) {
- return errorHandler({ error, functionName: "getWorldDetails", message: "Error getting world details" });
+ return standardizeError(error);
}
};
diff --git a/server/utils/world/initializeWorldDataObject.ts b/server/utils/world/initializeWorldDataObject.ts
index ad69a37..539f305 100644
--- a/server/utils/world/initializeWorldDataObject.ts
+++ b/server/utils/world/initializeWorldDataObject.ts
@@ -1,5 +1,5 @@
import { Credentials } from "../../types/Credentials.js";
-import { errorHandler } from "../errorHandler.js";
+import { standardizeError } from "../standardizeError.js";
import { DroppedAsset } from "../topiaInit.js";
export const initializeWorldDataObject = async ({ credentials, world }: { credentials: Credentials; world: any }) => {
@@ -67,11 +67,7 @@ export const initializeWorldDataObject = async ({ credentials, world }: { creden
await world.fetchDataObject();
return;
} catch (error) {
- errorHandler({
- error,
- functionName: "initializeWorldDataObject",
- message: "Error initializing world data object",
- });
+ standardizeError(error);
return await world.fetchDataObject();
}
};