From bc74a8e2c02627ac82049cdbc2771c38b156b4e7 Mon Sep 17 00:00:00 2001 From: Terraform Date: Sat, 30 Aug 2025 23:07:45 -0700 Subject: [PATCH 01/12] Fix release workflow to trigger production deployment --- .github/workflows/aws_auto_release.yml | 152 +++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 .github/workflows/aws_auto_release.yml diff --git a/.github/workflows/aws_auto_release.yml b/.github/workflows/aws_auto_release.yml new file mode 100644 index 0000000..7fabd78 --- /dev/null +++ b/.github/workflows/aws_auto_release.yml @@ -0,0 +1,152 @@ +name: Auto Release on Main Merge +on: + pull_request: + types: [closed] + branches: + - main + +concurrency: + group: ${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: read + +jobs: + auto_release: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: main + token: ${{ secrets.PAT }} + + - name: Check for release labels and determine version bumps + id: check + run: | + labels='${{ toJson(github.event.pull_request.labels.*.name) }}' + echo "PR Labels: $labels" + + has_release_label=false + has_major=false + has_minor=false + has_patch=false + + # Check if release label exists + if echo "$labels" | grep -q "release"; then + has_release_label=true + + # Check for each type of version bump + if echo "$labels" | grep -q "major"; then + has_major=true + fi + if echo "$labels" | grep -q "minor"; then + has_minor=true + fi + if echo "$labels" | grep -q "patch"; then + has_patch=true + fi + + # If no specific version type is specified, default to patch + if [[ "$has_major" == "false" && "$has_minor" == "false" && "$has_patch" == "false" ]]; then + has_patch=true + fi + fi + + echo "should_release=$has_release_label" >> $GITHUB_OUTPUT + echo "has_major=$has_major" >> $GITHUB_OUTPUT + echo "has_minor=$has_minor" >> $GITHUB_OUTPUT + echo "has_patch=$has_patch" >> $GITHUB_OUTPUT + echo "Should release: $has_release_label" + echo "Has major: $has_major, minor: $has_minor, patch: $has_patch" + + - name: Setup Node.js + if: steps.check.outputs.should_release == 'true' + uses: actions/setup-node@v4 + with: + node-version: 20.10 + + - name: Calculate new version with cumulative bumps + if: steps.check.outputs.should_release == 'true' + id: version + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + # Get the latest tag from git + latest_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Latest git tag: $latest_tag" + + # Remove 'v' prefix if present + current_version=${latest_tag#v} + echo "Current version: $current_version" + + # Parse current version + IFS='.' read -r major minor patch <<< "$current_version" + echo "Parsed version - Major: $major, Minor: $minor, Patch: $patch" + + # Apply cumulative version bumps + if [[ "${{ steps.check.outputs.has_major }}" == "true" ]]; then + major=$((major + 1)) + minor=0 # Reset minor when major is bumped + patch=0 # Reset patch when major is bumped + echo "Applied major bump: $major.0.0" + fi + + if [[ "${{ steps.check.outputs.has_minor }}" == "true" ]]; then + minor=$((minor + 1)) + if [[ "${{ steps.check.outputs.has_major }}" != "true" ]]; then + patch=0 # Reset patch when minor is bumped (only if major wasn't bumped) + fi + echo "Applied minor bump: $major.$minor.$patch" + fi + + if [[ "${{ steps.check.outputs.has_patch }}" == "true" ]]; then + patch=$((patch + 1)) + echo "Applied patch bump: $major.$minor.$patch" + fi + + new_version="$major.$minor.$patch" + echo "Final calculated version: $new_version" + + # Create package.json if it doesn't exist + if [[ ! -f "package.json" ]]; then + echo '{"version": "0.0.0"}' > package.json + fi + + # Update package.json with new version + npm version $new_version --no-git-tag-version --allow-same-version + + echo "NEW_VERSION=v$new_version" >> $GITHUB_ENV + echo "New version will be: v$new_version" + + - name: Create Release + if: steps.check.outputs.should_release == 'true' + uses: softprops/action-gh-release@v2 + with: + token: ${{ secrets.PAT }} # Use PAT to trigger other workflows + tag_name: ${{ env.NEW_VERSION }} + name: "Release ${{ env.NEW_VERSION }}" + generate_release_notes: true + make_latest: true + body: | + ## 🚀 Release ${{ env.NEW_VERSION }} + + **Version Bumps Applied:** + - Major: ${{ steps.check.outputs.has_major }} + - Minor: ${{ steps.check.outputs.has_minor }} + - Patch: ${{ steps.check.outputs.has_patch }} + + **Triggered by:** PR #${{ github.event.pull_request.number }} - ${{ github.event.pull_request.title }} + **Merged by:** @${{ github.event.pull_request.merged_by.login }} + + ### Changes in this PR + ${{ github.event.pull_request.body }} + + --- + *This release was automatically created by the Auto Release workflow* + From f1d37272ff6345a18d484f5183c024ff4f3f0f0f Mon Sep 17 00:00:00 2001 From: Terraform Date: Sun, 31 Aug 2025 18:07:02 -0700 Subject: [PATCH 02/12] Fix release workflow to trigger production deployment --- .github/workflows/aws_auto_release.yml | 95 ++++++++++++++++++++++++-- 1 file changed, 91 insertions(+), 4 deletions(-) diff --git a/.github/workflows/aws_auto_release.yml b/.github/workflows/aws_auto_release.yml index 7fabd78..d00ba20 100644 --- a/.github/workflows/aws_auto_release.yml +++ b/.github/workflows/aws_auto_release.yml @@ -23,9 +23,96 @@ jobs: with: fetch-depth: 0 ref: main - token: ${{ secrets.PAT }} + token: ${{ secrets.PAT }} + + - name: Check if user is authorized + id: auth_check + run: | + merged_by="${{ github.event.pull_request.merged_by.login }}" + echo "PR was merged by: $merged_by" + + # Get authorized users from CODEOWNERS file + authorized_users=() + + # Read CODEOWNERS file if it exists + if [[ -f ".github/CODEOWNERS" ]]; then + echo "📋 Reading CODEOWNERS file..." + # Extract usernames from CODEOWNERS (remove @ prefix) + codeowners=$(grep -v '^#' .github/CODEOWNERS | grep -o '@[a-zA-Z0-9_-]*' | sed 's/@//' | sort -u) + for user in $codeowners; do + authorized_users+=("$user") + echo " - CODEOWNER: $user" + done + else + echo "⚠️ No CODEOWNERS file found" + fi + + # Get repository collaborators with admin/maintain permissions using GitHub API + echo "🔍 Checking repository permissions..." + + # Check if user has admin or maintain permissions + user_permission=$(curl -s -H "Authorization: token ${{ secrets.PAT }}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${{ github.repository }}/collaborators/$merged_by/permission" | \ + jq -r '.permission // "none"') + + echo "User $merged_by has permission level: $user_permission" + + # Check if user is authorized + is_authorized=false + + # Check if user is in CODEOWNERS + for user in "${authorized_users[@]}"; do + if [[ "$user" == "$merged_by" ]]; then + is_authorized=true + echo "✅ User $merged_by is authorized via CODEOWNERS" + break + fi + done + + # Check if user has admin or maintain permissions + if [[ "$user_permission" == "admin" || "$user_permission" == "maintain" ]]; then + is_authorized=true + echo "✅ User $merged_by is authorized via repository permissions ($user_permission)" + fi + + # Check if user is organization owner (for metaversecloud-com org) + org_response=$(curl -s -H "Authorization: token ${{ secrets.PAT }}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/orgs/metaversecloud-com/members/$merged_by" \ + -w "%{http_code}") + + # Extract HTTP status code from the response + http_code=${org_response: -3} + + if [[ "$http_code" == "200" ]]; then + # Check if user is an owner + owner_status=$(curl -s -H "Authorization: token ${{ secrets.PAT }}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/orgs/metaversecloud-com/memberships/$merged_by" | \ + jq -r '.role // "none"') + + if [[ "$owner_status" == "admin" ]]; then + is_authorized=true + echo "✅ User $merged_by is authorized as organization owner" + fi + fi + + echo "is_authorized=$is_authorized" >> $GITHUB_OUTPUT + + if [[ "$is_authorized" == "false" ]]; then + echo "❌ User $merged_by is not authorized to trigger releases" + echo "💡 Authorized users include:" + echo " - CODEOWNERS: ${authorized_users[*]}" + echo " - Repository admins and maintainers" + echo " - Organization owners" + exit 0 + else + echo "🎉 User $merged_by is authorized to trigger releases" + fi - name: Check for release labels and determine version bumps + if: steps.auth_check.outputs.is_authorized == 'true' id: check run: | labels='${{ toJson(github.event.pull_request.labels.*.name) }}' @@ -65,13 +152,13 @@ jobs: echo "Has major: $has_major, minor: $has_minor, patch: $has_patch" - name: Setup Node.js - if: steps.check.outputs.should_release == 'true' + if: steps.auth_check.outputs.is_authorized == 'true' && steps.check.outputs.should_release == 'true' uses: actions/setup-node@v4 with: node-version: 20.10 - name: Calculate new version with cumulative bumps - if: steps.check.outputs.should_release == 'true' + if: steps.auth_check.outputs.is_authorized == 'true' && steps.check.outputs.should_release == 'true' id: version run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" @@ -125,7 +212,7 @@ jobs: echo "New version will be: v$new_version" - name: Create Release - if: steps.check.outputs.should_release == 'true' + if: steps.auth_check.outputs.is_authorized == 'true' && steps.check.outputs.should_release == 'true' uses: softprops/action-gh-release@v2 with: token: ${{ secrets.PAT }} # Use PAT to trigger other workflows From 1c572724e44eba91fe13144196c8dff64fb9baee Mon Sep 17 00:00:00 2001 From: Terraform Date: Fri, 5 Sep 2025 10:52:07 -0700 Subject: [PATCH 03/12] Add production release CICD --- .github/workflows/aws_prod_release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/aws_prod_release.yml b/.github/workflows/aws_prod_release.yml index 43caaf6..3550e1a 100644 --- a/.github/workflows/aws_prod_release.yml +++ b/.github/workflows/aws_prod_release.yml @@ -50,7 +50,7 @@ jobs: cache: 'npm' - run: git config --global user.email devops@topia.io - run: git config --global user.name Devops - - run: npm version --workspaces --include-workspace-root true ${{ github.event.release.tag_name }} + - run: npm version --no-git-tag-version --workspaces --include-workspace-root true ${{ github.event.release.tag_name }} - run: npm i - run: CI=false npm run build From 746bd0c52b39bee55dc6ae314a30f7251bb24c93 Mon Sep 17 00:00:00 2001 From: Lina Date: Thu, 11 Dec 2025 12:02:33 -0800 Subject: [PATCH 04/12] badges --- client/src/components/Leaderboard.tsx | 4 +- client/src/components/PageContainer.tsx | 10 +-- client/src/context/reducer.ts | 2 +- package-lock.json | 8 +- package.json | 2 +- .../droppedAssets/handleDropQuestItem.ts | 6 +- .../droppedAssets/handleGetQuestDetails.ts | 10 ++- .../droppedAssets/handleGetQuestItems.ts | 5 +- .../droppedAssets/handleQuestItemClicked.ts | 81 ++++++++++++++++--- server/controllers/handleGetLeaderboard.ts | 14 +++- .../controllers/handleRemoveQuestFromWorld.ts | 10 ++- .../controllers/handleUpdateAdminSettings.ts | 13 ++- server/utils/droppedAssets/getKeyAsset.ts | 8 +- server/utils/droppedAssets/getQuestItems.ts | 8 +- .../utils/droppedAssets/removeQuestItems.ts | 13 ++- server/utils/getDefaultKeyAssetImage.ts | 8 +- server/utils/getRandomCoordinates.ts | 2 +- server/utils/grantBadge.ts | 31 +++++++ server/utils/index.ts | 2 + server/utils/standardizedError.ts | 27 +++++++ server/utils/topiaInit.ts | 13 ++- server/utils/visitors/getVisitor.ts | 24 ++++-- server/utils/world/getWorldDetails.ts | 18 ++++- .../utils/world/initializeWorldDataObject.ts | 8 +- 24 files changed, 241 insertions(+), 86 deletions(-) create mode 100644 server/utils/grantBadge.ts create mode 100644 server/utils/standardizedError.ts diff --git a/client/src/components/Leaderboard.tsx b/client/src/components/Leaderboard.tsx index 1cae0cb..e0e045b 100644 --- a/client/src/components/Leaderboard.tsx +++ b/client/src/components/Leaderboard.tsx @@ -26,7 +26,7 @@ export const Leaderboard = ({ isKeyAsset }: { isKeyAsset: boolean }) => { // context const dispatch = useContext(GlobalDispatchContext); - const { questDetails, visitor } = useContext(GlobalStateContext); + const { questDetails } = useContext(GlobalStateContext); const { questItemImage } = questDetails || {}; useEffect(() => { @@ -49,7 +49,7 @@ export const Leaderboard = ({ isKeyAsset }: { isKeyAsset: boolean }) => { }) .catch(() => console.error("There was a problem while retrieving leaderboard data. Please try again later.")) .finally(() => setIsLoading(false)); - }, [visitor]); + }, []); if (isLoading) return ; diff --git a/client/src/components/PageContainer.tsx b/client/src/components/PageContainer.tsx index a35d1f0..332498f 100644 --- a/client/src/components/PageContainer.tsx +++ b/client/src/components/PageContainer.tsx @@ -24,14 +24,8 @@ export const PageContainer = ({ children, isLoading }: { children: ReactNode; is

Quest

- {showSettings ? ( - - ) : ( - <> - {children} - {error &&

{error}

} - - )} + {showSettings ? : children} + {error &&

{error}

} ); }; diff --git a/client/src/context/reducer.ts b/client/src/context/reducer.ts index 6bb829e..2e02c6b 100644 --- a/client/src/context/reducer.ts +++ b/client/src/context/reducer.ts @@ -28,7 +28,7 @@ const globalReducer = (state: InitialState, action: ActionType) => { case SET_ERROR: return { ...state, - error: payload?.error, + error: payload.error, }; default: { throw new Error(`Unhandled action type: ${type}`); diff --git a/package-lock.json b/package-lock.json index 1690db4..b9ac002 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.18.3", "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.18.3", + "resolved": "https://registry.npmjs.org/@rtsdk/topia/-/topia-0.18.3.tgz", + "integrity": "sha512-4R6OkocupgVsbsFbOJHaFk83YW1X5zVXKt2ss+jlDX1nbaX6balcXe6PAjjuoVMA2qBbtY4zO7uMUigxepmrrA==" }, "node_modules/@sdk-quest/client": { "resolved": "client", diff --git a/package.json b/package.json index 1f8586c..a1696e4 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.18.3", "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..c0ba932 100644 --- a/server/controllers/droppedAssets/handleGetQuestDetails.ts +++ b/server/controllers/droppedAssets/handleGetQuestDetails.ts @@ -5,9 +5,15 @@ 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 } = getVisitorResponse; return res.json({ questDetails: dataObject, 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..1f47b0d 100644 --- a/server/controllers/droppedAssets/handleQuestItemClicked.ts +++ b/server/controllers/droppedAssets/handleQuestItemClicked.ts @@ -11,6 +11,7 @@ import { getRandomCoordinates, getVisitor, getWorldDetails, + grantBadge, } from "../../utils/index.js"; import { AxiosError } from "axios"; @@ -29,14 +30,32 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { 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; + // Grant First Find badge if visitor collected their first quest item + if (totalCollected === 0) { + grantBadge({ credentials, visitor, visitorInventory, badgeName: "First Find" }).catch((error) => + errorHandler({ + error, + functionName: "handleQuestItemClicked", + message: "Error granting First Find badge", + }), + ); + } + const differenceInDays = getDifferenceInDays(currentDate, new Date(lastCollectedDate)); const hasCollectedToday = differenceInDays === 0; @@ -72,7 +91,28 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { totalCollected = totalCollected + 1; if (!hasCollectedToday) { totalCollectedToday = 1; - if (differenceInDays === 1) currentStreak = currentStreak + 1; + if (differenceInDays === 1) { + currentStreak = currentStreak + 1; + + // Grant Streak badges if visitor collected their item for 3 or 5 days in a row + if (currentStreak === 3) { + grantBadge({ credentials, visitor, visitorInventory, badgeName: "3-Day Streak" }).catch((error) => + errorHandler({ + error, + functionName: "handleQuestItemClicked", + message: "Error granting 3-Day Streak badge", + }), + ); + } else if (currentStreak === 5) { + grantBadge({ credentials, visitor, visitorInventory, badgeName: "5-Day Streak" }).catch((error) => + errorHandler({ + error, + functionName: "handleQuestItemClicked", + message: "Error granting 5-Day Streak badge", + }), + ); + } + } } else { totalCollectedToday = totalCollectedToday + 1; } @@ -97,18 +137,30 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { urlSlug, }, ]); + + // Grant Inventory Pro badge if visitor has collected all allowed quest items for the day + grantBadge({ credentials, visitor, visitorInventory, badgeName: "Inventory Pro" }).catch((error) => + errorHandler({ + error, + functionName: "handleQuestItemClicked", + message: "Error granting 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) { @@ -130,6 +182,7 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { ); 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 🔎"; @@ -150,7 +203,11 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { ); } - 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( { diff --git a/server/controllers/handleGetLeaderboard.ts b/server/controllers/handleGetLeaderboard.ts index 4c93057..988314a 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,7 +41,10 @@ export const handleGetLeaderboard = async (req: Request, res: Response) => { formattedLeaderboard.sort((a, b) => b.collected - a.collected); - const { visitor } = await getVisitor(credentials, keyAssetId); + const getVisitorResponse = await getVisitor(credentials, keyAssetId); + if (getVisitorResponse instanceof Error) throw getVisitorResponse; + + const { visitor } = getVisitorResponse; return res.json({ leaderboard: formattedLeaderboard, 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/utils/droppedAssets/getKeyAsset.ts b/server/utils/droppedAssets/getKeyAsset.ts index 6d58e5c..00cbc7d 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 { standardizedError } from "../standardizedError.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 standardizedError(error); } }; diff --git a/server/utils/droppedAssets/getQuestItems.ts b/server/utils/droppedAssets/getQuestItems.ts index 0503415..a76525c 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 { standardizedError } from "../standardizedError.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 standardizedError(error); } }; diff --git a/server/utils/droppedAssets/removeQuestItems.ts b/server/utils/droppedAssets/removeQuestItems.ts index 957b393..1ccc61a 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 { standardizedError } from "../standardizedError.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 standardizedError(error); } }; diff --git a/server/utils/getDefaultKeyAssetImage.ts b/server/utils/getDefaultKeyAssetImage.ts index 115e9df..9dce13f 100644 --- a/server/utils/getDefaultKeyAssetImage.ts +++ b/server/utils/getDefaultKeyAssetImage.ts @@ -1,4 +1,4 @@ -import { errorHandler } from "./errorHandler.js"; +import { standardizedError } from "./standardizedError.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 standardizedError(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/grantBadge.ts b/server/utils/grantBadge.ts new file mode 100644 index 0000000..7b024a6 --- /dev/null +++ b/server/utils/grantBadge.ts @@ -0,0 +1,31 @@ +import { Credentials } from "../types"; +import { standardizedError } from "./standardizedError"; +import { Ecosystem } from "./topiaInit"; + +export const grantBadge = 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); + + return { success: true }; + } catch (error: any) { + return standardizedError(error); + } +}; diff --git a/server/utils/index.ts b/server/utils/index.ts index 478c6a0..7d30242 100644 --- a/server/utils/index.ts +++ b/server/utils/index.ts @@ -9,4 +9,6 @@ export * from "./getCredentials.js"; export * from "./getDefaultKeyAssetImage.js"; export * from "./getDifferenceInDays.js"; export * from "./getRandomCoordinates.js"; +export * from "./grantBadge.js"; +export * from "./standardizedError.js"; export * from "./topiaInit.js"; diff --git a/server/utils/standardizedError.ts b/server/utils/standardizedError.ts new file mode 100644 index 0000000..b626f83 --- /dev/null +++ b/server/utils/standardizedError.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 standardizedError = (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..7cac56d 100644 --- a/server/utils/visitors/getVisitor.ts +++ b/server/utils/visitors/getVisitor.ts @@ -1,8 +1,8 @@ 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 { standardizedError } from "../standardizedError.js"; export const getVisitor = async (credentials: Credentials, keyAssetId: string) => { try { @@ -40,12 +40,22 @@ 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) { + // @ts-ignore + const { id, name = "", image_url } = item; + + visitorInventory[name] = { + id, + icon: image_url, + name, + }; + } + + return { visitor, visitorProgress, visitorInventory }; } catch (error) { - return errorHandler({ - error, - functionName: "getVisitor", - message: "Error getting visitor", - }); + return standardizedError(error); } }; diff --git a/server/utils/world/getWorldDetails.ts b/server/utils/world/getWorldDetails.ts index 1dd3780..f7b5122 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 { standardizedError } from "../standardizedError.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 standardizedError(error); } }; diff --git a/server/utils/world/initializeWorldDataObject.ts b/server/utils/world/initializeWorldDataObject.ts index ad69a37..5a1ae6a 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 { standardizedError } from "../standardizedError.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", - }); + standardizedError(error); return await world.fetchDataObject(); } }; From 99051d3c2048b6279b7f024cbf3065383a24df79 Mon Sep 17 00:00:00 2001 From: Lina Date: Tue, 20 Jan 2026 18:51:28 -0800 Subject: [PATCH 05/12] Badges --- .../droppedAssets/handleQuestItemClicked.ts | 189 +++++++++++------- server/types/DataObjectTypes.ts | 18 +- server/utils/{grantBadge.ts => awardBadge.ts} | 7 +- server/utils/droppedAssets/getKeyAsset.ts | 4 +- server/utils/droppedAssets/getQuestItems.ts | 4 +- .../utils/droppedAssets/removeQuestItems.ts | 4 +- server/utils/getDefaultKeyAssetImage.ts | 4 +- server/utils/index.ts | 4 +- ...andardizedError.ts => standardizeError.ts} | 2 +- server/utils/visitors/getVisitor.ts | 18 +- server/utils/world/getWorldDetails.ts | 4 +- .../utils/world/initializeWorldDataObject.ts | 4 +- 12 files changed, 158 insertions(+), 104 deletions(-) rename server/utils/{grantBadge.ts => awardBadge.ts} (80%) rename server/utils/{standardizedError.ts => standardizeError.ts} (93%) diff --git a/server/controllers/droppedAssets/handleQuestItemClicked.ts b/server/controllers/droppedAssets/handleQuestItemClicked.ts index 1f47b0d..82445d8 100644 --- a/server/controllers/droppedAssets/handleQuestItemClicked.ts +++ b/server/controllers/droppedAssets/handleQuestItemClicked.ts @@ -11,7 +11,7 @@ import { getRandomCoordinates, getVisitor, getWorldDetails, - grantBadge, + awardBadge, } from "../../utils/index.js"; import { AxiosError } from "axios"; @@ -26,7 +26,8 @@ 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 }); @@ -45,14 +46,16 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { let { currentStreak, lastCollectedDate, longestStreak, totalCollected, totalCollectedToday } = visitorProgress; - // Grant First Find badge if visitor collected their first quest item + // Award First Find badge if visitor collected their first quest item if (totalCollected === 0) { - grantBadge({ credentials, visitor, visitorInventory, badgeName: "First Find" }).catch((error) => - errorHandler({ - error, - functionName: "handleQuestItemClicked", - message: "Error granting First Find badge", - }), + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "First Find" }).catch((error) => + errorHandler({ + error, + functionName: "handleQuestItemClicked", + message: "Error awarding First Find badge", + }), + ), ); } @@ -64,7 +67,6 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { if (hasCollectedToday && totalCollectedToday >= numberAllowedToCollect) { return res.json({ addedClick: false, numberAllowedToCollect, questDetails: worldDataObject }); } else { - const promises = []; analytics.push({ analyticName: "itemsCollected" }); // Move the quest item to a new random location @@ -80,36 +82,63 @@ 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 25"; + else if (totalCollected === 50) veteranBadgeName = "Quest Veteran 50"; + else if (totalCollected === 75) veteranBadgeName = "Quest Veteran 75"; + else if (totalCollected === 100) veteranBadgeName = "Quest Veteran 100"; + 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; - // Grant Streak badges if visitor collected their item for 3 or 5 days in a row + // Award Streak badges if visitor collected their item for 3 or 5 days in a row if (currentStreak === 3) { - grantBadge({ credentials, visitor, visitorInventory, badgeName: "3-Day Streak" }).catch((error) => - errorHandler({ - error, - functionName: "handleQuestItemClicked", - message: "Error granting 3-Day Streak badge", - }), + 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) { - grantBadge({ credentials, visitor, visitorInventory, badgeName: "5-Day Streak" }).catch((error) => - errorHandler({ - error, - functionName: "handleQuestItemClicked", - message: "Error granting 5-Day Streak badge", - }), + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "5-Day Streak" }).catch((error) => + errorHandler({ + error, + functionName: "handleQuestItemClicked", + message: "Error awarding 5-Day Streak badge", + }), + ), ); } } @@ -120,31 +149,37 @@ 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, + }, + ]), + ); - // Grant Inventory Pro badge if visitor has collected all allowed quest items for the day - grantBadge({ credentials, visitor, visitorInventory, badgeName: "Inventory Pro" }).catch((error) => - errorHandler({ - error, - functionName: "handleQuestItemClicked", - message: "Error granting Inventory Pro badge", - }), + // 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", + }), + ), ); } @@ -167,40 +202,45 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { analytics.push({ analyticName: `itemsCollected${totalCollected}`, profileId, uniqueKey: profileId }); const name = process.env.EMOTE_NAME || "quest_1"; - const grantExpressionResult = await visitor.grantExpression({ name }); + // @ts-ignore + const awardExpressionResult = await visitor.awardExpression({ name }); let title = "🔎 New Emote Unlocked", 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", - }), + if (awardExpressionResult.data?.statusCode === 200 || awardExpressionResult.status === 200) { + 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) { + } else if (awardExpressionResult.data?.statusCode === 409 || awardExpressionResult.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 getKeyAssetResponse = await getKeyAsset(credentials, keyAssetId); @@ -217,7 +257,10 @@ 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); + }); return res.json({ addedClick: true, 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/grantBadge.ts b/server/utils/awardBadge.ts similarity index 80% rename from server/utils/grantBadge.ts rename to server/utils/awardBadge.ts index 7b024a6..45bcdfa 100644 --- a/server/utils/grantBadge.ts +++ b/server/utils/awardBadge.ts @@ -1,8 +1,7 @@ import { Credentials } from "../types"; -import { standardizedError } from "./standardizedError"; -import { Ecosystem } from "./topiaInit"; +import { Ecosystem, standardizeError } from "./index.js"; -export const grantBadge = async ({ +export const awardBadge = async ({ credentials, visitor, visitorInventory, @@ -26,6 +25,6 @@ export const grantBadge = async ({ return { success: true }; } catch (error: any) { - return standardizedError(error); + return standardizeError(error); } }; diff --git a/server/utils/droppedAssets/getKeyAsset.ts b/server/utils/droppedAssets/getKeyAsset.ts index 00cbc7d..e2fc9da 100644 --- a/server/utils/droppedAssets/getKeyAsset.ts +++ b/server/utils/droppedAssets/getKeyAsset.ts @@ -1,7 +1,7 @@ import { DroppedAsset } from "../topiaInit.js"; import { Credentials } from "../../types/Credentials.js"; import { KeyAssetDataObjectType } from "../../types/DataObjectTypes.js"; -import { standardizedError } from "../standardizedError.js"; +import { standardizeError } from "../standardizeError.js"; export const getKeyAsset = async (credentials: Credentials, keyAssetId: string) => { try { @@ -27,6 +27,6 @@ export const getKeyAsset = async (credentials: Credentials, keyAssetId: string) return keyAsset; } catch (error) { - return standardizedError(error); + return standardizeError(error); } }; diff --git a/server/utils/droppedAssets/getQuestItems.ts b/server/utils/droppedAssets/getQuestItems.ts index a76525c..6f0ab97 100644 --- a/server/utils/droppedAssets/getQuestItems.ts +++ b/server/utils/droppedAssets/getQuestItems.ts @@ -1,6 +1,6 @@ import { World } from "../topiaInit.js"; import { Credentials } from "../../types/Credentials.js"; -import { standardizedError } from "../standardizedError.js"; +import { standardizeError } from "../standardizeError.js"; export const getQuestItems = async (credentials: Credentials) => { try { @@ -18,6 +18,6 @@ export const getQuestItems = async (credentials: Credentials) => { return questItems; } catch (error) { - return standardizedError(error); + return standardizeError(error); } }; diff --git a/server/utils/droppedAssets/removeQuestItems.ts b/server/utils/droppedAssets/removeQuestItems.ts index 1ccc61a..c92d77f 100644 --- a/server/utils/droppedAssets/removeQuestItems.ts +++ b/server/utils/droppedAssets/removeQuestItems.ts @@ -1,7 +1,7 @@ import { World } from "../topiaInit.js"; import { getQuestItems } from "./getQuestItems.js"; import { Credentials } from "../../types/Credentials"; -import { standardizedError } from "../standardizedError.js"; +import { standardizeError } from "../standardizeError.js"; export const removeQuestItems = async (credentials: Credentials) => { try { @@ -25,6 +25,6 @@ export const removeQuestItems = async (credentials: Credentials) => { return { success: true }; } catch (error) { - return standardizedError(error); + return standardizeError(error); } }; diff --git a/server/utils/getDefaultKeyAssetImage.ts b/server/utils/getDefaultKeyAssetImage.ts index 9dce13f..73ad93f 100644 --- a/server/utils/getDefaultKeyAssetImage.ts +++ b/server/utils/getDefaultKeyAssetImage.ts @@ -1,4 +1,4 @@ -import { standardizedError } from "./standardizedError.js"; +import { standardizeError } from "./standardizeError.js"; export const getDefaultKeyAssetImage = async (urlSlug: string) => { try { @@ -11,6 +11,6 @@ export const getDefaultKeyAssetImage = async (urlSlug: string) => { return questItemImage; } catch (error) { - return standardizedError(error); + return standardizeError(error); } }; diff --git a/server/utils/index.ts b/server/utils/index.ts index 7d30242..c72c635 100644 --- a/server/utils/index.ts +++ b/server/utils/index.ts @@ -9,6 +9,6 @@ export * from "./getCredentials.js"; export * from "./getDefaultKeyAssetImage.js"; export * from "./getDifferenceInDays.js"; export * from "./getRandomCoordinates.js"; -export * from "./grantBadge.js"; -export * from "./standardizedError.js"; +export * from "./awardBadge.js"; +export * from "./standardizeError.js"; export * from "./topiaInit.js"; diff --git a/server/utils/standardizedError.ts b/server/utils/standardizeError.ts similarity index 93% rename from server/utils/standardizedError.ts rename to server/utils/standardizeError.ts index b626f83..8723ffb 100644 --- a/server/utils/standardizedError.ts +++ b/server/utils/standardizeError.ts @@ -2,7 +2,7 @@ * Creates a standardized error object from various error types * This helps provide consistent error formatting across the application */ -export const standardizedError = (error: unknown): Error => { +export const standardizeError = (error: unknown): Error => { // If error is already an Error instance, return it directly if (error instanceof Error) { return error; diff --git a/server/utils/visitors/getVisitor.ts b/server/utils/visitors/getVisitor.ts index 7cac56d..6302814 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 { Credentials } from "../../types/Credentials.js"; -import { UserDataObjectType } from "../../types/DataObjectTypes.js"; -import { standardizedError } from "../standardizedError.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; @@ -56,6 +66,6 @@ export const getVisitor = async (credentials: Credentials, keyAssetId: string) = return { visitor, visitorProgress, visitorInventory }; } catch (error) { - return standardizedError(error); + return standardizeError(error); } }; diff --git a/server/utils/world/getWorldDetails.ts b/server/utils/world/getWorldDetails.ts index f7b5122..8884161 100644 --- a/server/utils/world/getWorldDetails.ts +++ b/server/utils/world/getWorldDetails.ts @@ -1,7 +1,7 @@ import { World } from "../topiaInit.js"; import { initializeWorldDataObject } from "./initializeWorldDataObject.js"; import { Credentials, WorldDataObjectType } from "../../types/index.js"; -import { standardizedError } from "../standardizedError.js"; +import { standardizeError } from "../standardizeError.js"; import { WorldInterface } from "@rtsdk/topia"; type WorldDataObject = { @@ -34,6 +34,6 @@ export const getWorldDetails = async ( return { dataObject: dataObject.scenes?.[sceneDropId], world: world as WorldType }; } catch (error) { - return standardizedError(error); + return standardizeError(error); } }; diff --git a/server/utils/world/initializeWorldDataObject.ts b/server/utils/world/initializeWorldDataObject.ts index 5a1ae6a..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 { standardizedError } from "../standardizedError.js"; +import { standardizeError } from "../standardizeError.js"; import { DroppedAsset } from "../topiaInit.js"; export const initializeWorldDataObject = async ({ credentials, world }: { credentials: Credentials; world: any }) => { @@ -67,7 +67,7 @@ export const initializeWorldDataObject = async ({ credentials, world }: { creden await world.fetchDataObject(); return; } catch (error) { - standardizedError(error); + standardizeError(error); return await world.fetchDataObject(); } }; From 9b098434b1acadccd5b9b58c0632cff2aac9c28d Mon Sep 17 00:00:00 2001 From: Lina Date: Mon, 2 Feb 2026 10:05:47 -0800 Subject: [PATCH 06/12] badges UI --- client/src/context/reducer.ts | 2 + client/src/context/types.ts | 9 ++ client/src/pages/Home.tsx | 42 +++++++-- package-lock.json | 8 +- package.json | 2 +- .../droppedAssets/handleGetQuestDetails.ts | 35 +++++++- .../droppedAssets/handleQuestItemClicked.ts | 8 +- server/utils/awardBadge.ts | 7 ++ server/utils/index.ts | 3 +- server/utils/inventoryCache.ts | 88 +++++++++++++++++++ server/utils/visitors/getVisitor.ts | 15 ++-- 11 files changed, 195 insertions(+), 24 deletions(-) create mode 100644 server/utils/inventoryCache.ts diff --git a/client/src/context/reducer.ts b/client/src/context/reducer.ts index 2e02c6b..b03ee61 100644 --- a/client/src/context/reducer.ts +++ b/client/src/context/reducer.ts @@ -19,11 +19,13 @@ const globalReducer = (state: InitialState, action: ActionType) => { return { ...state, questDetails: payload.questDetails, + badges: payload.badges, }; case SET_VISITOR_INFO: return { ...state, visitor: payload.visitor, + visitorInventory: payload.visitorInventory, }; case SET_ERROR: return { diff --git a/client/src/context/types.ts b/client/src/context/types.ts index f56a144..1db9ff4 100644 --- a/client/src/context/types.ts +++ b/client/src/context/types.ts @@ -25,6 +25,15 @@ export interface InitialState { isAdmin: boolean; profileId: string; }; + visitorInventory?: { [name: string]: { id: string; icon: string; name: string } }; + badges?: { + [name: string]: { + id: string; + name: string; + icon: string; + description: string; + }; + }; error?: string; } diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 082fc76..a16922b 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -11,25 +11,26 @@ import { ErrorType, SET_QUEST_DETAILS, SET_VISITOR_INFO } from "@/context/types" import { backendAPI, setErrorMessage } from "@/utils"; export const Home = () => { + const [activeTab, setActiveTab] = useState("leaderboard"); const [isLoading, setIsLoading] = useState(true); // context const dispatch = useContext(GlobalDispatchContext); - const { hasInteractiveParams } = useContext(GlobalStateContext); + const { hasInteractiveParams, badges, visitorInventory } = useContext(GlobalStateContext); useEffect(() => { if (hasInteractiveParams) { backendAPI .get("/quest") .then((response) => { - const { questDetails, visitor } = response.data; + const { questDetails, visitor, visitorInventory, badges } = response.data; dispatch!({ type: SET_QUEST_DETAILS, - payload: { questDetails }, + payload: { questDetails, badges }, }); dispatch!({ type: SET_VISITOR_INFO, - payload: { visitor }, + payload: { visitor, visitorInventory }, }); }) .catch((error) => setErrorMessage(dispatch, error as ErrorType)) @@ -39,7 +40,38 @@ export const Home = () => { return ( - + <> +
+ + +
+ + {activeTab === "leaderboard" ? ( + + ) : ( +
+ {badges && + Object.values(badges).map((badge) => { + const hasBadge = visitorInventory && Object.keys(visitorInventory).includes(badge.name); + const style = { width: "90px", filter: "none" }; + if (!hasBadge) style.filter = "grayscale(1)"; + return ( +
+ {badge.name} + {badge.name} +
+ ); + })} +
+ )} +
); }; diff --git a/package-lock.json b/package-lock.json index b9ac002..d064eee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ ], "dependencies": { "@googleapis/sheets": "^7.0.0", - "@rtsdk/topia": "^0.18.3", + "@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.18.3", - "resolved": "https://registry.npmjs.org/@rtsdk/topia/-/topia-0.18.3.tgz", - "integrity": "sha512-4R6OkocupgVsbsFbOJHaFk83YW1X5zVXKt2ss+jlDX1nbaX6balcXe6PAjjuoVMA2qBbtY4zO7uMUigxepmrrA==" + "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 a1696e4..a82a0ea 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@googleapis/sheets": "^7.0.0", - "@rtsdk/topia": "^0.18.3", + "@rtsdk/topia": "^0.19.4", "axios": "^1.6.8", "concurrently": "^8.2.2", "typescript": "^5.4.3", diff --git a/server/controllers/droppedAssets/handleGetQuestDetails.ts b/server/controllers/droppedAssets/handleGetQuestDetails.ts index c0ba932..b61f4aa 100644 --- a/server/controllers/droppedAssets/handleGetQuestDetails.ts +++ b/server/controllers/droppedAssets/handleGetQuestDetails.ts @@ -1,5 +1,11 @@ import { Request, Response } from "express"; -import { errorHandler, getCredentials, getVisitor, getWorldDetails } from "../../utils/index.js"; +import { + errorHandler, + getCachedInventoryItems, + getCredentials, + getVisitor, + getWorldDetails, +} from "../../utils/index.js"; export const handleGetQuestDetails = async (req: Request, res: Response) => { try { @@ -13,11 +19,36 @@ export const handleGetQuestDetails = async (req: Request, res: Response) => { const getVisitorResponse = await getVisitor(credentials, credentials.assetId); if (getVisitorResponse instanceof Error) throw getVisitorResponse; - const { visitor } = getVisitorResponse; + const { visitor, visitorInventory } = getVisitorResponse; + + 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 res.json({ questDetails: dataObject, visitor: { isAdmin: visitor.isAdmin, profileId: credentials.profileId }, + visitorInventory, + badges, }); } catch (error) { return errorHandler({ diff --git a/server/controllers/droppedAssets/handleQuestItemClicked.ts b/server/controllers/droppedAssets/handleQuestItemClicked.ts index 82445d8..0e9abe2 100644 --- a/server/controllers/droppedAssets/handleQuestItemClicked.ts +++ b/server/controllers/droppedAssets/handleQuestItemClicked.ts @@ -98,10 +98,10 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { // Award Quest Veteran badges if visitor collected [x] quest items let veteranBadgeName; - if (totalCollected === 25) veteranBadgeName = "Quest Veteran 25"; - else if (totalCollected === 50) veteranBadgeName = "Quest Veteran 50"; - else if (totalCollected === 75) veteranBadgeName = "Quest Veteran 75"; - else if (totalCollected === 100) veteranBadgeName = "Quest Veteran 100"; + 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) => diff --git a/server/utils/awardBadge.ts b/server/utils/awardBadge.ts index 45bcdfa..5066363 100644 --- a/server/utils/awardBadge.ts +++ b/server/utils/awardBadge.ts @@ -23,6 +23,13 @@ export const awardBadge = async ({ 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/index.ts b/server/utils/index.ts index c72c635..264bf77 100644 --- a/server/utils/index.ts +++ b/server/utils/index.ts @@ -2,6 +2,7 @@ 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 "./getBaseURL.js"; @@ -9,6 +10,6 @@ export * from "./getCredentials.js"; export * from "./getDefaultKeyAssetImage.js"; export * from "./getDifferenceInDays.js"; export * from "./getRandomCoordinates.js"; -export * from "./awardBadge.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..30ca3bb --- /dev/null +++ b/server/utils/inventoryCache.ts @@ -0,0 +1,88 @@ +import { Credentials } from "../types"; +import { Ecosystem } from "./topiaInit.js"; +import { standardizeError } from "./standardizeError.js"; +import { InventoryItemInterface } from "@rtsdk/topia"; + +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, + 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/visitors/getVisitor.ts b/server/utils/visitors/getVisitor.ts index 6302814..cace78b 100644 --- a/server/utils/visitors/getVisitor.ts +++ b/server/utils/visitors/getVisitor.ts @@ -54,14 +54,15 @@ export const getVisitor = async ( let visitorInventory: { [key: string]: { id: string; icon: string; name: string } } = {}; for (const item of visitor.inventoryItems) { - // @ts-ignore - const { id, name = "", image_url } = item; + const { id, name = "", image_url, status, type } = item; - visitorInventory[name] = { - id, - icon: image_url, - name, - }; + if (status === "ACTIVE" && type === "BADGE") { + visitorInventory[name] = { + id, + icon: image_url, + name, + }; + } } return { visitor, visitorProgress, visitorInventory }; From 6293ee3edc8a2035f79a609823a8e693b77084d8 Mon Sep 17 00:00:00 2001 From: Lina Date: Mon, 2 Feb 2026 11:20:14 -0800 Subject: [PATCH 07/12] add inventory to leaderboard component --- client/src/components/Leaderboard.tsx | 4 ++-- server/controllers/handleGetLeaderboard.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/client/src/components/Leaderboard.tsx b/client/src/components/Leaderboard.tsx index e0e045b..104f190 100644 --- a/client/src/components/Leaderboard.tsx +++ b/client/src/components/Leaderboard.tsx @@ -33,11 +33,11 @@ export const Leaderboard = ({ isKeyAsset }: { isKeyAsset: boolean }) => { backendAPI .get(`/leaderboard?isKeyAsset=${isKeyAsset}`) .then((response) => { - const { leaderboard, visitor } = response.data; + const { leaderboard, visitor, visitorInventory } = response.data; dispatch!({ type: SET_VISITOR_INFO, - payload: { visitor }, + payload: { visitor, visitorInventory }, }); const index = leaderboard.findIndex((item: { profileId: string }) => item.profileId === visitor.profileId); diff --git a/server/controllers/handleGetLeaderboard.ts b/server/controllers/handleGetLeaderboard.ts index 988314a..8aa3245 100644 --- a/server/controllers/handleGetLeaderboard.ts +++ b/server/controllers/handleGetLeaderboard.ts @@ -44,11 +44,12 @@ export const handleGetLeaderboard = async (req: Request, res: Response) => { const getVisitorResponse = await getVisitor(credentials, keyAssetId); if (getVisitorResponse instanceof Error) throw getVisitorResponse; - const { visitor } = getVisitorResponse; + const { visitor, visitorInventory } = getVisitorResponse; return res.json({ leaderboard: formattedLeaderboard, visitor: { isAdmin: visitor.isAdmin, profileId: credentials.profileId }, + visitorInventory, }); } catch (error) { return errorHandler({ From e7c0f2ef6fb46a6b615290ffb1476ffd51a7989f Mon Sep 17 00:00:00 2001 From: Lina Date: Mon, 2 Feb 2026 12:28:41 -0800 Subject: [PATCH 08/12] update grantExpression --- server/controllers/droppedAssets/handleQuestItemClicked.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/server/controllers/droppedAssets/handleQuestItemClicked.ts b/server/controllers/droppedAssets/handleQuestItemClicked.ts index 0e9abe2..792e771 100644 --- a/server/controllers/droppedAssets/handleQuestItemClicked.ts +++ b/server/controllers/droppedAssets/handleQuestItemClicked.ts @@ -202,13 +202,12 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { analytics.push({ analyticName: `itemsCollected${totalCollected}`, profileId, uniqueKey: profileId }); const name = process.env.EMOTE_NAME || "quest_1"; - // @ts-ignore - const awardExpressionResult = await visitor.awardExpression({ name }); + const grantExpressionResult = await visitor.grantExpression({ name }); let title = "🔎 New Emote Unlocked", text = "Congrats! Your detective skills paid off."; // @ts-ignore - if (awardExpressionResult.data?.statusCode === 200 || awardExpressionResult.status === 200) { + if (grantExpressionResult.data?.statusCode === 200 || grantExpressionResult.status === 200) { promises.push( visitor.triggerParticle({ name: "firework2_gold" }).catch((error: AxiosError) => errorHandler({ @@ -221,7 +220,7 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { analytics.push({ analyticName: `${name}-emoteUnlocked`, urlSlug, uniqueKey: urlSlug }); // @ts-ignore - } else if (awardExpressionResult.data?.statusCode === 409 || awardExpressionResult.status === 409) { + } else if (grantExpressionResult.data?.statusCode === 409 || grantExpressionResult.status === 409) { title = `Congrats! You collected ${totalCollected} quest items`; text = "Keep up the solid detective work 🔎"; } From 5a38e0a3b3cdbafc73adcfe438beb0b6b0a81d5c Mon Sep 17 00:00:00 2001 From: Lina Date: Mon, 2 Feb 2026 15:47:04 -0800 Subject: [PATCH 09/12] add inventory sort --- server/utils/inventoryCache.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/server/utils/inventoryCache.ts b/server/utils/inventoryCache.ts index 30ca3bb..b64a919 100644 --- a/server/utils/inventoryCache.ts +++ b/server/utils/inventoryCache.ts @@ -1,7 +1,17 @@ import { Credentials } from "../types"; import { Ecosystem } from "./topiaInit.js"; import { standardizeError } from "./standardizeError.js"; -import { InventoryItemInterface } from "@rtsdk/topia"; +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[]; @@ -44,7 +54,19 @@ export const getCachedInventoryItems = async ({ // Update cache inventoryCache = { - items: ecosystem.inventoryItems, + 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, }; From fc9dbf5eb6d0a9a57152c6d779f18085ece318c4 Mon Sep 17 00:00:00 2001 From: Lina Date: Mon, 2 Feb 2026 17:31:09 -0800 Subject: [PATCH 10/12] add badges to QuestItemClicked --- client/src/components/Leaderboard.tsx | 20 +++---- client/src/components/PageContainer.tsx | 52 +++++++++++++++++-- client/src/pages/Home.tsx | 40 ++------------ client/src/pages/QuestItemClicked.tsx | 47 ++++++++--------- .../droppedAssets/handleGetQuestDetails.ts | 31 +---------- .../droppedAssets/handleQuestItemClicked.ts | 18 ++++++- server/controllers/handleGetLeaderboard.ts | 7 --- server/utils/getBadges.ts | 34 ++++++++++++ server/utils/index.ts | 1 + 9 files changed, 136 insertions(+), 114 deletions(-) create mode 100644 server/utils/getBadges.ts diff --git a/client/src/components/Leaderboard.tsx b/client/src/components/Leaderboard.tsx index 104f190..e819bbf 100644 --- a/client/src/components/Leaderboard.tsx +++ b/client/src/components/Leaderboard.tsx @@ -4,11 +4,11 @@ import { useContext, useEffect, useState } from "react"; import { Loading } from "./Loading"; // context -import { GlobalDispatchContext, GlobalStateContext } from "@context/GlobalContext"; -import { SET_VISITOR_INFO } from "@/context/types"; +import { GlobalStateContext } from "@context/GlobalContext"; // utils import { backendAPI } from "@utils/backendAPI"; +import { useSearchParams } from "react-router-dom"; type LeaderboardType = { name: string; @@ -25,22 +25,18 @@ export const Leaderboard = ({ isKeyAsset }: { isKeyAsset: boolean }) => { const [isLoading, setIsLoading] = useState(true); // context - const dispatch = useContext(GlobalDispatchContext); const { questDetails } = useContext(GlobalStateContext); const { questItemImage } = questDetails || {}; + const [searchParams] = useSearchParams(); + const profileId = searchParams.get("profileId"); + useEffect(() => { backendAPI .get(`/leaderboard?isKeyAsset=${isKeyAsset}`) .then((response) => { - const { leaderboard, visitor, visitorInventory } = response.data; - - dispatch!({ - type: SET_VISITOR_INFO, - payload: { visitor, visitorInventory }, - }); - - const index = leaderboard.findIndex((item: { profileId: string }) => item.profileId === visitor.profileId); + const { leaderboard } = response.data; + const index = leaderboard.findIndex((item: { profileId: string }) => item.profileId === profileId); setMyData(leaderboard[index]); setCurrentPosition(index + 1); setTotal(leaderboard.length); @@ -53,8 +49,6 @@ export const Leaderboard = ({ isKeyAsset }: { isKeyAsset: boolean }) => { if (isLoading) return ; - if (visibleData.length === 0) return

No quest items have been found yet. Search the world and be the first!

; - return (
{currentPosition && currentPosition > 0 ? ( diff --git a/client/src/components/PageContainer.tsx b/client/src/components/PageContainer.tsx index 332498f..f12f740 100644 --- a/client/src/components/PageContainer.tsx +++ b/client/src/components/PageContainer.tsx @@ -6,25 +6,69 @@ import { AdminIconButton, Loading, Admin } from "@/components"; // context import { GlobalStateContext } from "@context/GlobalContext"; -export const PageContainer = ({ children, isLoading }: { children: ReactNode; isLoading: boolean }) => { - const { error, questDetails, visitor } = useContext(GlobalStateContext); +export const PageContainer = ({ + children, + isLoading, + showAdminIcon, +}: { + children: ReactNode; + isLoading: boolean; + showAdminIcon: boolean; +}) => { + const { error, questDetails, visitor, badges, visitorInventory } = useContext(GlobalStateContext); const { questItemImage } = questDetails || {}; const { isAdmin } = visitor || {}; + const [activeTab, setActiveTab] = useState("leaderboard"); const [showSettings, setShowSettings] = useState(false); if (isLoading) return ; return (
- {isAdmin && ( + {isAdmin && showAdminIcon && ( setShowSettings(!showSettings)} showSettings={showSettings} /> )} {questItemImage ? Find me :
}

Quest

- {showSettings ? : children} + {showSettings ? ( + + ) : ( + <> +
+ + +
+ + {activeTab === "leaderboard" ? ( + children + ) : ( +
+ {badges && + Object.values(badges).map((badge) => { + const hasBadge = visitorInventory && Object.keys(visitorInventory).includes(badge.name); + const style = { width: "90px", filter: "none" }; + if (!hasBadge) style.filter = "grayscale(1)"; + return ( +
+ {badge.name} + {badge.name} +
+ ); + })} +
+ )} + + )} {error &&

{error}

}
); diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index a16922b..8e1d47f 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -11,19 +11,18 @@ import { ErrorType, SET_QUEST_DETAILS, SET_VISITOR_INFO } from "@/context/types" import { backendAPI, setErrorMessage } from "@/utils"; export const Home = () => { - const [activeTab, setActiveTab] = useState("leaderboard"); const [isLoading, setIsLoading] = useState(true); // context const dispatch = useContext(GlobalDispatchContext); - const { hasInteractiveParams, badges, visitorInventory } = useContext(GlobalStateContext); + const { hasInteractiveParams } = useContext(GlobalStateContext); useEffect(() => { if (hasInteractiveParams) { backendAPI .get("/quest") .then((response) => { - const { questDetails, visitor, visitorInventory, badges } = response.data; + const { questDetails, visitor, badges, visitorInventory } = response.data; dispatch!({ type: SET_QUEST_DETAILS, payload: { questDetails, badges }, @@ -39,39 +38,8 @@ export const Home = () => { }, [hasInteractiveParams]); return ( - - <> -
- - -
- - {activeTab === "leaderboard" ? ( - - ) : ( -
- {badges && - Object.values(badges).map((badge) => { - const hasBadge = visitorInventory && Object.keys(visitorInventory).includes(badge.name); - const style = { width: "90px", filter: "none" }; - if (!hasBadge) style.filter = "grayscale(1)"; - return ( -
- {badge.name} - {badge.name} -
- ); - })} -
- )} - + + ); }; diff --git a/client/src/pages/QuestItemClicked.tsx b/client/src/pages/QuestItemClicked.tsx index e8fb57a..c144d2e 100644 --- a/client/src/pages/QuestItemClicked.tsx +++ b/client/src/pages/QuestItemClicked.tsx @@ -1,11 +1,11 @@ import { useContext, useEffect, useState } from "react"; // components -import { Leaderboard, Loading } from "@/components"; +import { Leaderboard, Loading, PageContainer } from "@/components"; // context import { GlobalDispatchContext, GlobalStateContext } from "@context/GlobalContext"; -import { SET_QUEST_DETAILS } from "@/context/types"; +import { SET_QUEST_DETAILS, SET_VISITOR_INFO } from "@/context/types"; // utils import { backendAPI } from "@utils/backendAPI"; @@ -17,18 +17,29 @@ export const QuestItemClicked = () => { // context const dispatch = useContext(GlobalDispatchContext); - const { questDetails, hasInteractiveParams } = useContext(GlobalStateContext); - const { questItemImage } = questDetails || {}; + const { hasInteractiveParams } = useContext(GlobalStateContext); useEffect(() => { if (hasInteractiveParams) { backendAPI .post("/quest-item-clicked") .then((response) => { - const { addedClick, numberAllowedToCollect, totalCollectedToday, questDetails } = response.data; + const { + addedClick, + numberAllowedToCollect, + totalCollectedToday, + questDetails, + visitor, + visitorInventory, + badges, + } = response.data; dispatch!({ type: SET_QUEST_DETAILS, - payload: { questDetails }, + payload: { questDetails, badges }, + }); + dispatch!({ + type: SET_VISITOR_INFO, + payload: { visitor, visitorInventory }, }); if (addedClick) { setCollectedText(`${totalCollectedToday}/${numberAllowedToCollect} collected today`); @@ -50,25 +61,13 @@ export const QuestItemClicked = () => { if (isLoading) return ; return ( -
- {questItemImage ? Find me :
} -
-

Quest

-
-
- {message && ( -
-

{message}

-
- )} - {collectedText && ( -
-

{collectedText}

-
- )} + +
+ {message &&

{message}

} + {collectedText &&

{collectedText}

} +
- -
+ ); }; diff --git a/server/controllers/droppedAssets/handleGetQuestDetails.ts b/server/controllers/droppedAssets/handleGetQuestDetails.ts index b61f4aa..891aba0 100644 --- a/server/controllers/droppedAssets/handleGetQuestDetails.ts +++ b/server/controllers/droppedAssets/handleGetQuestDetails.ts @@ -1,11 +1,5 @@ import { Request, Response } from "express"; -import { - errorHandler, - getCachedInventoryItems, - 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 { @@ -21,28 +15,7 @@ export const handleGetQuestDetails = async (req: Request, res: Response) => { const { visitor, visitorInventory } = getVisitorResponse; - 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 || "", - }; - } - } + const badges = await getBadges(credentials); return res.json({ questDetails: dataObject, diff --git a/server/controllers/droppedAssets/handleQuestItemClicked.ts b/server/controllers/droppedAssets/handleQuestItemClicked.ts index 792e771..28d5601 100644 --- a/server/controllers/droppedAssets/handleQuestItemClicked.ts +++ b/server/controllers/droppedAssets/handleQuestItemClicked.ts @@ -12,6 +12,7 @@ import { getVisitor, getWorldDetails, awardBadge, + getBadges, } from "../../utils/index.js"; import { AxiosError } from "axios"; @@ -46,6 +47,8 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { 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) { promises.push( @@ -65,7 +68,13 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { 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 { analytics.push({ analyticName: "itemsCollected" }); @@ -261,11 +270,18 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { 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 8aa3245..d585fc2 100644 --- a/server/controllers/handleGetLeaderboard.ts +++ b/server/controllers/handleGetLeaderboard.ts @@ -41,15 +41,8 @@ export const handleGetLeaderboard = async (req: Request, res: Response) => { formattedLeaderboard.sort((a, b) => b.collected - a.collected); - const getVisitorResponse = await getVisitor(credentials, keyAssetId); - if (getVisitorResponse instanceof Error) throw getVisitorResponse; - - const { visitor, visitorInventory } = getVisitorResponse; - return res.json({ leaderboard: formattedLeaderboard, - visitor: { isAdmin: visitor.isAdmin, profileId: credentials.profileId }, - visitorInventory, }); } catch (error) { return errorHandler({ 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/index.ts b/server/utils/index.ts index 264bf77..a5844e8 100644 --- a/server/utils/index.ts +++ b/server/utils/index.ts @@ -5,6 +5,7 @@ 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"; From 4d01a568269ff305862ce6e0830dddd5d67a505b Mon Sep 17 00:00:00 2001 From: Terraform Date: Tue, 3 Feb 2026 20:52:02 +0000 Subject: [PATCH 11/12] Fix release workflow to trigger production deployment --- .github/workflows/aws_auto_release.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/aws_auto_release.yml b/.github/workflows/aws_auto_release.yml index d00ba20..c265155 100644 --- a/.github/workflows/aws_auto_release.yml +++ b/.github/workflows/aws_auto_release.yml @@ -36,7 +36,7 @@ jobs: # Read CODEOWNERS file if it exists if [[ -f ".github/CODEOWNERS" ]]; then - echo "📋 Reading CODEOWNERS file..." + echo "[INFO] Reading CODEOWNERS file..." # Extract usernames from CODEOWNERS (remove @ prefix) codeowners=$(grep -v '^#' .github/CODEOWNERS | grep -o '@[a-zA-Z0-9_-]*' | sed 's/@//' | sort -u) for user in $codeowners; do @@ -44,11 +44,11 @@ jobs: echo " - CODEOWNER: $user" done else - echo "⚠️ No CODEOWNERS file found" + echo "[WARN] No CODEOWNERS file found" fi # Get repository collaborators with admin/maintain permissions using GitHub API - echo "🔍 Checking repository permissions..." + echo "[CHECK] Checking repository permissions..." # Check if user has admin or maintain permissions user_permission=$(curl -s -H "Authorization: token ${{ secrets.PAT }}" \ @@ -65,7 +65,7 @@ jobs: for user in "${authorized_users[@]}"; do if [[ "$user" == "$merged_by" ]]; then is_authorized=true - echo "✅ User $merged_by is authorized via CODEOWNERS" + echo "[OK] User $merged_by is authorized via CODEOWNERS" break fi done @@ -73,7 +73,7 @@ jobs: # Check if user has admin or maintain permissions if [[ "$user_permission" == "admin" || "$user_permission" == "maintain" ]]; then is_authorized=true - echo "✅ User $merged_by is authorized via repository permissions ($user_permission)" + echo "[OK] User $merged_by is authorized via repository permissions ($user_permission)" fi # Check if user is organization owner (for metaversecloud-com org) @@ -94,21 +94,21 @@ jobs: if [[ "$owner_status" == "admin" ]]; then is_authorized=true - echo "✅ User $merged_by is authorized as organization owner" + echo "[OK] User $merged_by is authorized as organization owner" fi fi echo "is_authorized=$is_authorized" >> $GITHUB_OUTPUT if [[ "$is_authorized" == "false" ]]; then - echo "❌ User $merged_by is not authorized to trigger releases" - echo "💡 Authorized users include:" + echo "[ERROR] User $merged_by is not authorized to trigger releases" + echo "[TIP] Authorized users include:" echo " - CODEOWNERS: ${authorized_users[*]}" echo " - Repository admins and maintainers" echo " - Organization owners" exit 0 else - echo "🎉 User $merged_by is authorized to trigger releases" + echo "[SUCCESS] User $merged_by is authorized to trigger releases" fi - name: Check for release labels and determine version bumps @@ -221,7 +221,7 @@ jobs: generate_release_notes: true make_latest: true body: | - ## 🚀 Release ${{ env.NEW_VERSION }} + ## ? Release ${{ env.NEW_VERSION }} **Version Bumps Applied:** - Major: ${{ steps.check.outputs.has_major }} From 0391927f603cbf2ed00d2879cc654233ac885bee Mon Sep 17 00:00:00 2001 From: Lina Date: Wed, 4 Feb 2026 15:59:17 -0800 Subject: [PATCH 12/12] update first find logic --- server/controllers/droppedAssets/handleQuestItemClicked.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/controllers/droppedAssets/handleQuestItemClicked.ts b/server/controllers/droppedAssets/handleQuestItemClicked.ts index 28d5601..b1ca082 100644 --- a/server/controllers/droppedAssets/handleQuestItemClicked.ts +++ b/server/controllers/droppedAssets/handleQuestItemClicked.ts @@ -50,7 +50,7 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { const badges = await getBadges(credentials); // Award First Find badge if visitor collected their first quest item - if (totalCollected === 0) { + if (totalCollected === 0 || !visitorInventory["First Find"]) { promises.push( awardBadge({ credentials, visitor, visitorInventory, badgeName: "First Find" }).catch((error) => errorHandler({