From 2179fcefc15e37d440e60c8afc506067c883bc46 Mon Sep 17 00:00:00 2001 From: Terraform Date: Sat, 30 Aug 2025 23:07:44 -0700 Subject: [PATCH 01/20] 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 17980ce9ca385b719b76f564d3d5f22932212889 Mon Sep 17 00:00:00 2001 From: Terraform Date: Sun, 31 Aug 2025 18:07:18 -0700 Subject: [PATCH 02/20] 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 23c543b162984d39bfce5dee2f65756cb6320d7f Mon Sep 17 00:00:00 2001 From: Terraform Date: Fri, 5 Sep 2025 10:51:49 -0700 Subject: [PATCH 03/20] 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 332b6a4b14facba9c639bff5bb6b50087c165a5b Mon Sep 17 00:00:00 2001 From: Juan Pablo Lorier Date: Wed, 10 Sep 2025 14:27:02 -0300 Subject: [PATCH 04/20] changes to improve redis client --- server/redis/redis.js | 162 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 140 insertions(+), 22 deletions(-) diff --git a/server/redis/redis.js b/server/redis/redis.js index 74c68c9..8ca4052 100644 --- a/server/redis/redis.js +++ b/server/redis/redis.js @@ -1,24 +1,131 @@ -import { createClient } from "redis"; +import * as redis from "redis"; import dotenv from "dotenv"; dotenv.config({ path: "../.env" }); -const redisConfig = { - url: process.env.REDIS_URL, - socket: { - tls: process.env.REDIS_URL?.startsWith("rediss"), - }, +// Health/retry config +const RAPID_RETRY_MAX = 10; +const RAPID_ERROR_THRESHOLD = 5000; // ms + +// Publisher health state +let pubRapidErrorCount = 0; +let pubReconnectionAttempt = 0; +let pubLastReconnectAttemptTime = null; +let pubLastConnectionTime = null; + +// Subscriber health state +let subRapidErrorCount = 0; +let subReconnectionAttempt = 0; +let subLastReconnectAttemptTime = null; +let subLastConnectionTime = null; + +const getRedisHealth = (name) => { + const currentTime = Date.now(); + const lastConnectionTime = name === "pub" ? pubLastConnectionTime : subLastConnectionTime; + const lastReconnectAttemptTime = name === "pub" ? pubLastReconnectAttemptTime : subLastReconnectAttemptTime; + const rapidReconnectCount = name === "pub" ? pubRapidErrorCount : subRapidErrorCount; + const reconnectCount = name === "pub" ? pubReconnectionAttempt : subReconnectionAttempt; + const status = rapidReconnectCount < RAPID_RETRY_MAX ? "OK" : "UNHEALTHY"; + const timeSinceLastReconnectAttempt = lastReconnectAttemptTime ? currentTime - lastReconnectAttemptTime : null; + + return { + status, + currentTime, + lastConnectionTime, + rapidReconnectCount, + reconnectCount, + timeSinceLastReconnectAttempt, + }; }; -const redisObj = { - publisher: createClient(redisConfig), - subscriber: createClient(redisConfig), +const handleRedisConnection = (client, name) => { + const { reconnectCount, currentTime, status } = getRedisHealth(name); + const info = reconnectCount ? `status: ${status}, reconnectCount: ${reconnectCount}` : `status: ${status}`; + console.log(`Redis connected - ${name} server, on process: ${process.pid}`, info); + + if (name === "pub") pubLastConnectionTime = currentTime; + if (name === "sub") subLastConnectionTime = currentTime; + + client.health = getRedisHealth(name); +}; + +const handleRedisReconnection = (name) => { + const { currentTime, timeSinceLastReconnectAttempt } = getRedisHealth(name); + + if (name === "pub") { + pubLastReconnectAttemptTime = currentTime; + pubReconnectionAttempt++; + if (timeSinceLastReconnectAttempt && timeSinceLastReconnectAttempt < RAPID_ERROR_THRESHOLD) { + pubRapidErrorCount++; + } + } + + if (name === "sub") { + subLastReconnectAttemptTime = currentTime; + subReconnectionAttempt++; + if (timeSinceLastReconnectAttempt && timeSinceLastReconnectAttempt < RAPID_ERROR_THRESHOLD) { + subRapidErrorCount++; + } + } +}; + +const handleRedisError = (name, error) => { + const { reconnectCount, rapidReconnectCount, status, timeSinceLastReconnectAttempt } = getRedisHealth(name); + const info = reconnectCount + ? `status: ${status}, reconnectCount: ${reconnectCount}, rapidReconnectCount: ${rapidReconnectCount} timeSinceLastReconnectAttempt: ${timeSinceLastReconnectAttempt}` + : `status: ${status}`; + console.error(`Redis error - ${name} server, on process: ${process.pid}, ${info}`); + console.error(`Redis error details - ${error}`); +}; + +function getRedisClient(url = process.env.REDIS_URL) { + let isClusterMode = false; + if (typeof process.env.REDIS_CLUSTER_MODE === "undefined") { + console.log("[Redis] Environment variable REDIS_CLUSTER_MODE is not set. Defaulting to false."); + } else { + isClusterMode = process.env.REDIS_CLUSTER_MODE === "true"; + } + + const safeUrl = url || ""; + const parsedUrl = new URL(safeUrl); + const host = parsedUrl.hostname; + const port = parsedUrl.port ? parseInt(parsedUrl.port) : 6379; + const username = parsedUrl.username || "default"; + const password = parsedUrl.password || ""; + const tls = safeUrl.startsWith("rediss"); + + if (!isClusterMode) { + return redis.createClient({ + socket: { host, port, tls }, + username, + password, + url: safeUrl, + }); + } + + return redis.createCluster({ + useReplicas: true, + rootNodes: [ + { + url: safeUrl, + socket: { tls }, + }, + ], + defaults: { username, password }, + }); +} + +const gameManager = { + publisher: getRedisClient(), + subscriber: getRedisClient(), connections: [], publish: function (channel, message) { + if (process.env.NODE_ENV === "development") console.log(`Publishing ${JSON.stringify(message)} on ${channel}`); this.publisher.publish(channel, JSON.stringify(message)); }, subscribe: function (channel) { this.subscriber.subscribe(channel, (message) => { const data = JSON.parse(message); + if (process.env.NODE_ENV === "development") console.log(`Event received on ${channel}:`, data); this.connections.forEach(({ res: existingConnection }) => { const { profileId } = existingConnection.req.query; if (data.profileId === profileId) { @@ -29,7 +136,6 @@ const redisObj = { }, addConn: function (connection) { const { profileId, interactiveNonce } = connection.res.req.query; - if ( this.connections.some( ({ res: existingConnection }) => @@ -37,7 +143,6 @@ const redisObj = { existingConnection.req.query.profileId === profileId, ) ) { - // Replace old connection with new one this.connections.splice( this.connections.findIndex( ({ res: existingConnection }) => @@ -50,12 +155,16 @@ const redisObj = { } else { this.connections.push(connection); } + if (process.env.NODE_ENV === "development") { + console.log(`Connection ${interactiveNonce} added. Length is ${this.connections.length}`); + } }, deleteConn: function () { - // Remove inactive connections older than 30 minutes + // Remove inactive connections older than 10 minutes this.connections = this.connections.filter(({ res, lastHeartbeatTime }) => { - const isActive = lastHeartbeatTime > Date.now() - 30 * 60 * 1000; - if (!isActive) { + const isActive = lastHeartbeatTime > Date.now() - 10 * 60 * 1000; + if (!isActive && process.env.NODE_ENV === "development") { + console.log(`Connection to ${res.req.query.interactiveNonce} deleted`); } return isActive; }); @@ -68,18 +177,27 @@ const redisObj = { }, }; -redisObj.publisher.connect(); -redisObj.subscriber.connect(); +// Wire health handlers +gameManager.publisher.on("connect", () => handleRedisConnection(gameManager.publisher, "pub")); +gameManager.publisher.on("reconnecting", () => handleRedisReconnection("pub")); +gameManager.publisher.on("error", (err) => handleRedisError("pub", err)); + +gameManager.subscriber.on("connect", () => handleRedisConnection(gameManager.subscriber, "sub")); +gameManager.subscriber.on("reconnecting", () => handleRedisReconnection("sub")); +gameManager.subscriber.on("error", (err) => handleRedisError("sub", err)); -redisObj.subscribe(`${process.env.INTERACTIVE_KEY}_RACE`); +// Establish connections +gameManager.publisher.connect(); +gameManager.subscriber.connect(); -redisObj.publisher.on("error", (err) => console.error("Publisher Error", err)); -redisObj.subscriber.on("error", (err) => console.error("Subscriber Error", err)); +// Subscribe to race channel +gameManager.subscribe(`${process.env.INTERACTIVE_KEY}_RACE`); +// Periodically prune stale SSE connections setInterval(() => { - if (redisObj.connections.length > 0) { - redisObj.deleteConn(); + if (gameManager.connections.length > 0) { + gameManager.deleteConn(); } }, 1000 * 60); -export default redisObj; +export default gameManager; From f84e0a63f8f914f6513ff2bde21cab4c40aad964 Mon Sep 17 00:00:00 2001 From: Juan Pablo Lorier Date: Thu, 11 Sep 2025 16:48:32 -0300 Subject: [PATCH 05/20] improve connection if server not ready --- server/redis/redis.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/server/redis/redis.js b/server/redis/redis.js index 8ca4052..d6fb2e3 100644 --- a/server/redis/redis.js +++ b/server/redis/redis.js @@ -186,12 +186,20 @@ gameManager.subscriber.on("connect", () => handleRedisConnection(gameManager.sub gameManager.subscriber.on("reconnecting", () => handleRedisReconnection("sub")); gameManager.subscriber.on("error", (err) => handleRedisError("sub", err)); -// Establish connections -gameManager.publisher.connect(); -gameManager.subscriber.connect(); +// Initialize connections and subscription with proper sequencing +async function initRedis() { + try { + await gameManager.publisher.connect(); + await gameManager.subscriber.connect(); + // Subscribe only after connections are established + gameManager.subscribe(`${process.env.INTERACTIVE_KEY}_RACE`); + } catch (err) { + console.error("[Redis] Initialization error:", err); + } +} -// Subscribe to race channel -gameManager.subscribe(`${process.env.INTERACTIVE_KEY}_RACE`); +// Kick off initialization (top-level) +initRedis(); // Periodically prune stale SSE connections setInterval(() => { From fe823cb25ea18e05b57c5620925a7bfceeecd08d Mon Sep 17 00:00:00 2001 From: Juan Pablo Lorier Date: Thu, 11 Sep 2025 18:29:21 -0300 Subject: [PATCH 06/20] add verbose to client --- server/redis/redis.js | 49 +++++++++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/server/redis/redis.js b/server/redis/redis.js index d6fb2e3..43bc5c6 100644 --- a/server/redis/redis.js +++ b/server/redis/redis.js @@ -85,6 +85,8 @@ function getRedisClient(url = process.env.REDIS_URL) { isClusterMode = process.env.REDIS_CLUSTER_MODE === "true"; } + console.log(`[Redis] Creating Redis client - Cluster mode: ${isClusterMode}`); + const safeUrl = url || ""; const parsedUrl = new URL(safeUrl); const host = parsedUrl.hostname; @@ -93,7 +95,10 @@ function getRedisClient(url = process.env.REDIS_URL) { const password = parsedUrl.password || ""; const tls = safeUrl.startsWith("rediss"); + console.log(`[Redis] Connection details - Host: ${host}, Port: ${port}, TLS: ${tls}, Username: ${username}`); + if (!isClusterMode) { + console.log("[Redis] Creating standalone Redis client"); return redis.createClient({ socket: { host, port, tls }, username, @@ -102,6 +107,7 @@ function getRedisClient(url = process.env.REDIS_URL) { }); } + console.log("[Redis] Creating Redis cluster client"); return redis.createCluster({ useReplicas: true, rootNodes: [ @@ -122,17 +128,24 @@ const gameManager = { if (process.env.NODE_ENV === "development") console.log(`Publishing ${JSON.stringify(message)} on ${channel}`); this.publisher.publish(channel, JSON.stringify(message)); }, - subscribe: function (channel) { - this.subscriber.subscribe(channel, (message) => { - const data = JSON.parse(message); - if (process.env.NODE_ENV === "development") console.log(`Event received on ${channel}:`, data); - this.connections.forEach(({ res: existingConnection }) => { - const { profileId } = existingConnection.req.query; - if (data.profileId === profileId) { - existingConnection.write(`retry: 5000\ndata: ${JSON.stringify(data)}\n\n`); - } + subscribe: async function (channel) { + try { + console.log(`[Redis] Attempting to subscribe to channel: ${channel}`); + await this.subscriber.subscribe(channel, (message) => { + const data = JSON.parse(message); + if (process.env.NODE_ENV === "development") console.log(`Event received on ${channel}:`, data); + this.connections.forEach(({ res: existingConnection }) => { + const { profileId } = existingConnection.req.query; + if (data.profileId === profileId) { + existingConnection.write(`retry: 5000\ndata: ${JSON.stringify(data)}\n\n`); + } + }); }); - }); + console.log(`[Redis] Successfully subscribed to channel: ${channel}`); + } catch (error) { + console.error(`[Redis] Failed to subscribe to channel ${channel}:`, error); + throw error; + } }, addConn: function (connection) { const { profileId, interactiveNonce } = connection.res.req.query; @@ -189,12 +202,26 @@ gameManager.subscriber.on("error", (err) => handleRedisError("sub", err)); // Initialize connections and subscription with proper sequencing async function initRedis() { try { + console.log(`[Redis] INTERACTIVE_KEY: ${process.env.INTERACTIVE_KEY}`); + console.log(`[Redis] REDIS_URL: ${process.env.REDIS_URL ? 'SET' : 'NOT SET'}`); + console.log(`[Redis] REDIS_CLUSTER_MODE: ${process.env.REDIS_CLUSTER_MODE}`); + + console.log("[Redis] Connecting publisher..."); await gameManager.publisher.connect(); + console.log("[Redis] Publisher connected successfully"); + + console.log("[Redis] Connecting subscriber..."); await gameManager.subscriber.connect(); + console.log("[Redis] Subscriber connected successfully"); + // Subscribe only after connections are established - gameManager.subscribe(`${process.env.INTERACTIVE_KEY}_RACE`); + console.log(`[Redis] Subscribing to channel: ${process.env.INTERACTIVE_KEY}_RACE`); + await gameManager.subscribe(`${process.env.INTERACTIVE_KEY}_RACE`); + console.log("[Redis] Subscription established"); } catch (err) { console.error("[Redis] Initialization error:", err); + console.error("[Redis] Error details:", err.message); + console.error("[Redis] Stack trace:", err.stack); } } From 96abbe0eef0511b6a7b239397138f93c743286f9 Mon Sep 17 00:00:00 2001 From: Juan Pablo Lorier Date: Thu, 11 Sep 2025 18:44:42 -0300 Subject: [PATCH 07/20] more changes to use TLS in the connection --- server/redis/redis.js | 73 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/server/redis/redis.js b/server/redis/redis.js index 43bc5c6..f8aa0ca 100644 --- a/server/redis/redis.js +++ b/server/redis/redis.js @@ -88,23 +88,38 @@ function getRedisClient(url = process.env.REDIS_URL) { console.log(`[Redis] Creating Redis client - Cluster mode: ${isClusterMode}`); const safeUrl = url || ""; + console.log(`[Redis] Raw URL protocol: ${safeUrl.split('://')[0]}://`); // Log protocol specifically + console.log(`[Redis] URL starts with rediss://: ${safeUrl.startsWith("rediss://")}`); const parsedUrl = new URL(safeUrl); const host = parsedUrl.hostname; const port = parsedUrl.port ? parseInt(parsedUrl.port) : 6379; const username = parsedUrl.username || "default"; const password = parsedUrl.password || ""; - const tls = safeUrl.startsWith("rediss"); + const tls = safeUrl.startsWith("rediss://"); console.log(`[Redis] Connection details - Host: ${host}, Port: ${port}, TLS: ${tls}, Username: ${username}`); if (!isClusterMode) { console.log("[Redis] Creating standalone Redis client"); - return redis.createClient({ - socket: { host, port, tls }, + const clientConfig = { + socket: { + host, + port, + tls: tls ? { + // AWS ElastiCache specific TLS options + servername: host, + checkServerIdentity: () => undefined, // Disable hostname verification for ElastiCache + } : false, + connectTimeout: 10000, + lazyConnect: false + }, username, password, url: safeUrl, - }); + }; + console.log(`[Redis] Client config TLS enabled: ${!!clientConfig.socket.tls}`); + console.log(`[Redis] TLS servername: ${clientConfig.socket.tls ? clientConfig.socket.tls.servername : 'N/A'}`); + return redis.createClient(clientConfig); } console.log("[Redis] Creating Redis cluster client"); @@ -113,7 +128,13 @@ function getRedisClient(url = process.env.REDIS_URL) { rootNodes: [ { url: safeUrl, - socket: { tls }, + socket: { + tls: tls ? { + servername: host, + checkServerIdentity: () => undefined, + } : false, + connectTimeout: 10000 + }, }, ], defaults: { username, password }, @@ -194,10 +215,14 @@ const gameManager = { gameManager.publisher.on("connect", () => handleRedisConnection(gameManager.publisher, "pub")); gameManager.publisher.on("reconnecting", () => handleRedisReconnection("pub")); gameManager.publisher.on("error", (err) => handleRedisError("pub", err)); +gameManager.publisher.on("end", () => console.log("[Redis] Publisher connection ended")); +gameManager.publisher.on("ready", () => console.log("[Redis] Publisher is ready")); gameManager.subscriber.on("connect", () => handleRedisConnection(gameManager.subscriber, "sub")); gameManager.subscriber.on("reconnecting", () => handleRedisReconnection("sub")); gameManager.subscriber.on("error", (err) => handleRedisError("sub", err)); +gameManager.subscriber.on("end", () => console.log("[Redis] Subscriber connection ended")); +gameManager.subscriber.on("ready", () => console.log("[Redis] Subscriber is ready")); // Initialize connections and subscription with proper sequencing async function initRedis() { @@ -207,21 +232,43 @@ async function initRedis() { console.log(`[Redis] REDIS_CLUSTER_MODE: ${process.env.REDIS_CLUSTER_MODE}`); console.log("[Redis] Connecting publisher..."); - await gameManager.publisher.connect(); - console.log("[Redis] Publisher connected successfully"); + try { + await gameManager.publisher.connect(); + console.log("[Redis] Publisher connected successfully"); + } catch (pubError) { + console.error("[Redis] Publisher connection failed:", pubError.message); + throw pubError; + } console.log("[Redis] Connecting subscriber..."); - await gameManager.subscriber.connect(); - console.log("[Redis] Subscriber connected successfully"); + try { + await gameManager.subscriber.connect(); + console.log("[Redis] Subscriber connected successfully"); + } catch (subError) { + console.error("[Redis] Subscriber connection failed:", subError.message); + throw subError; + } // Subscribe only after connections are established - console.log(`[Redis] Subscribing to channel: ${process.env.INTERACTIVE_KEY}_RACE`); - await gameManager.subscribe(`${process.env.INTERACTIVE_KEY}_RACE`); - console.log("[Redis] Subscription established"); + const channelName = `${process.env.INTERACTIVE_KEY}_RACE`; + console.log(`[Redis] Subscribing to channel: ${channelName}`); + try { + await gameManager.subscribe(channelName); + console.log("[Redis] Subscription established successfully"); + } catch (subError) { + console.error("[Redis] Subscription failed:", subError.message); + throw subError; + } + + console.log("[Redis] Redis initialization completed successfully"); } catch (err) { console.error("[Redis] Initialization error:", err); console.error("[Redis] Error details:", err.message); - console.error("[Redis] Stack trace:", err.stack); + if (err.stack) { + console.error("[Redis] Stack trace:", err.stack); + } + // Don't re-throw to prevent app crash, but log the failure + console.error("[Redis] Redis will not be available for this session"); } } From b39ae9541003e0aa88176bbb94324811bdfd74b9 Mon Sep 17 00:00:00 2001 From: Lina Date: Thu, 11 Dec 2025 12:03:08 -0800 Subject: [PATCH 08/20] badges --- package-lock.json | 8 +- package.json | 2 +- server/controllers/handleCheckpointEntered.js | 9 +- server/controllers/handleRaceStart.js | 2 +- server/redis/redis.js | 52 ++++++------ server/utils/badges/getTimeInSeconds.js | 9 ++ server/utils/badges/grantBadge.js | 19 +++++ server/utils/badges/index.js | 3 + server/utils/badges/isNewHighScoreTop3.js | 12 +++ server/utils/checkpoints/finishLineEntered.js | 84 ++++++++++++++++++- server/utils/index.js | 1 + server/utils/topiaInit.js | 5 +- server/utils/visitors/getVisitor.js | 15 +++- .../utils/visitors/updateVisitorProgress.js | 5 ++ 14 files changed, 187 insertions(+), 39 deletions(-) create mode 100644 server/utils/badges/getTimeInSeconds.js create mode 100644 server/utils/badges/grantBadge.js create mode 100644 server/utils/badges/index.js create mode 100644 server/utils/badges/isNewHighScoreTop3.js diff --git a/package-lock.json b/package-lock.json index c0fedb9..1c28d85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ ], "dependencies": { "@googleapis/sheets": "^7.0.0", - "@rtsdk/topia": "^0.17.4", + "@rtsdk/topia": "^0.18.3", "axios": "^1.6.7", "body-parser": "^1.20.2", "concurrently": "^8.2.2", @@ -1018,9 +1018,9 @@ ] }, "node_modules/@rtsdk/topia": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@rtsdk/topia/-/topia-0.17.4.tgz", - "integrity": "sha512-/RTrB+FtsyktBPZ9OyBs3lWZWoLbP7Bt1YQFuxJwsKU5JtDWw1EOUo3qIpTdBpegd09KjQS0fAaXEbdOFBqKCg==" + "version": "0.18.3", + "resolved": "https://registry.npmjs.org/@rtsdk/topia/-/topia-0.18.3.tgz", + "integrity": "sha512-4R6OkocupgVsbsFbOJHaFk83YW1X5zVXKt2ss+jlDX1nbaX6balcXe6PAjjuoVMA2qBbtY4zO7uMUigxepmrrA==" }, "node_modules/@sdk-race/client": { "resolved": "client", diff --git a/package.json b/package.json index f128bb7..c47b456 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "dependencies": { "@googleapis/sheets": "^7.0.0", - "@rtsdk/topia": "^0.17.4", + "@rtsdk/topia": "^0.18.3", "axios": "^1.6.7", "body-parser": "^1.20.2", "concurrently": "^8.2.2", diff --git a/server/controllers/handleCheckpointEntered.js b/server/controllers/handleCheckpointEntered.js index e24de03..7e26982 100644 --- a/server/controllers/handleCheckpointEntered.js +++ b/server/controllers/handleCheckpointEntered.js @@ -26,7 +26,8 @@ export const handleCheckpointEntered = async (req, res) => { if (!startTimestamp) return { success: false, message: "Race has not started yet" }; - const cachedCheckpoints = JSON.parse(await redisObj.get(profileId)) || {}; + const { checkpoints: cachedCheckpoints, wasWrongCheckpointEntered } = + JSON.parse(await redisObj.get(profileId)) || {}; if (checkpointNumber !== 0) { if (checkpointNumber > 1 && !cachedCheckpoints[checkpointNumber - 2]) { @@ -48,6 +49,8 @@ export const handleCheckpointEntered = async (req, res) => { message: "Error firing toast", }), ); + + redisObj.set(profileId, JSON.stringify({ checkpoints: cachedCheckpoints, wasWrongCheckpointEntered: true })); return; } else { redisObj.publish(channel, { @@ -56,7 +59,7 @@ export const handleCheckpointEntered = async (req, res) => { currentRaceFinishedElapsedTime: null, }); cachedCheckpoints[checkpointNumber - 1] = true; - redisObj.set(profileId, JSON.stringify(cachedCheckpoints)); + redisObj.set(profileId, JSON.stringify({ checkpoints: cachedCheckpoints, wasWrongCheckpointEntered })); } } @@ -66,7 +69,7 @@ export const handleCheckpointEntered = async (req, res) => { checkpointNumber, currentRaceFinishedElapsedTime: currentElapsedTime, }); - const result = await finishLineEntered({ credentials, currentElapsedTime }); + const result = await finishLineEntered({ credentials, currentElapsedTime, wasWrongCheckpointEntered }); if (result instanceof Error) throw result; } else { const result = await checkpointEntered({ diff --git a/server/controllers/handleRaceStart.js b/server/controllers/handleRaceStart.js index 0b385e3..74840a0 100644 --- a/server/controllers/handleRaceStart.js +++ b/server/controllers/handleRaceStart.js @@ -16,7 +16,7 @@ export const handleRaceStart = async (req, res) => { const { identityId, displayName } = req.query; const startTimestamp = Date.now(); - redisObj.set(profileId, JSON.stringify({ 0: false })); + redisObj.set(profileId, JSON.stringify({ checkpoints: { 0: false }, wasWrongCheckpointEntered: false })); const world = World.create(urlSlug, { credentials }); world.triggerActivity({ type: WorldActivityType.GAME_ON, assetId }).catch((error) => diff --git a/server/redis/redis.js b/server/redis/redis.js index f8aa0ca..03431d8 100644 --- a/server/redis/redis.js +++ b/server/redis/redis.js @@ -88,7 +88,7 @@ function getRedisClient(url = process.env.REDIS_URL) { console.log(`[Redis] Creating Redis client - Cluster mode: ${isClusterMode}`); const safeUrl = url || ""; - console.log(`[Redis] Raw URL protocol: ${safeUrl.split('://')[0]}://`); // Log protocol specifically + console.log(`[Redis] Raw URL protocol: ${safeUrl.split("://")[0]}://`); // Log protocol specifically console.log(`[Redis] URL starts with rediss://: ${safeUrl.startsWith("rediss://")}`); const parsedUrl = new URL(safeUrl); const host = parsedUrl.hostname; @@ -102,23 +102,25 @@ function getRedisClient(url = process.env.REDIS_URL) { if (!isClusterMode) { console.log("[Redis] Creating standalone Redis client"); const clientConfig = { - socket: { - host, - port, - tls: tls ? { - // AWS ElastiCache specific TLS options - servername: host, - checkServerIdentity: () => undefined, // Disable hostname verification for ElastiCache - } : false, + socket: { + host, + port, + tls: tls + ? { + // AWS ElastiCache specific TLS options + servername: host, + checkServerIdentity: () => undefined, // Disable hostname verification for ElastiCache + } + : false, connectTimeout: 10000, - lazyConnect: false + lazyConnect: false, }, username, password, url: safeUrl, }; console.log(`[Redis] Client config TLS enabled: ${!!clientConfig.socket.tls}`); - console.log(`[Redis] TLS servername: ${clientConfig.socket.tls ? clientConfig.socket.tls.servername : 'N/A'}`); + console.log(`[Redis] TLS servername: ${clientConfig.socket.tls ? clientConfig.socket.tls.servername : "N/A"}`); return redis.createClient(clientConfig); } @@ -128,12 +130,14 @@ function getRedisClient(url = process.env.REDIS_URL) { rootNodes: [ { url: safeUrl, - socket: { - tls: tls ? { - servername: host, - checkServerIdentity: () => undefined, - } : false, - connectTimeout: 10000 + socket: { + tls: tls + ? { + servername: host, + checkServerIdentity: () => undefined, + } + : false, + connectTimeout: 10000, }, }, ], @@ -146,7 +150,7 @@ const gameManager = { subscriber: getRedisClient(), connections: [], publish: function (channel, message) { - if (process.env.NODE_ENV === "development") console.log(`Publishing ${JSON.stringify(message)} on ${channel}`); + // if (process.env.NODE_ENV === "development") console.log(`Publishing ${JSON.stringify(message)} on ${channel}`); this.publisher.publish(channel, JSON.stringify(message)); }, subscribe: async function (channel) { @@ -154,7 +158,7 @@ const gameManager = { console.log(`[Redis] Attempting to subscribe to channel: ${channel}`); await this.subscriber.subscribe(channel, (message) => { const data = JSON.parse(message); - if (process.env.NODE_ENV === "development") console.log(`Event received on ${channel}:`, data); + // if (process.env.NODE_ENV === "development") console.log(`Event received on ${channel}:`, data); this.connections.forEach(({ res: existingConnection }) => { const { profileId } = existingConnection.req.query; if (data.profileId === profileId) { @@ -228,9 +232,9 @@ gameManager.subscriber.on("ready", () => console.log("[Redis] Subscriber is read async function initRedis() { try { console.log(`[Redis] INTERACTIVE_KEY: ${process.env.INTERACTIVE_KEY}`); - console.log(`[Redis] REDIS_URL: ${process.env.REDIS_URL ? 'SET' : 'NOT SET'}`); + console.log(`[Redis] REDIS_URL: ${process.env.REDIS_URL ? "SET" : "NOT SET"}`); console.log(`[Redis] REDIS_CLUSTER_MODE: ${process.env.REDIS_CLUSTER_MODE}`); - + console.log("[Redis] Connecting publisher..."); try { await gameManager.publisher.connect(); @@ -239,7 +243,7 @@ async function initRedis() { console.error("[Redis] Publisher connection failed:", pubError.message); throw pubError; } - + console.log("[Redis] Connecting subscriber..."); try { await gameManager.subscriber.connect(); @@ -248,7 +252,7 @@ async function initRedis() { console.error("[Redis] Subscriber connection failed:", subError.message); throw subError; } - + // Subscribe only after connections are established const channelName = `${process.env.INTERACTIVE_KEY}_RACE`; console.log(`[Redis] Subscribing to channel: ${channelName}`); @@ -259,7 +263,7 @@ async function initRedis() { console.error("[Redis] Subscription failed:", subError.message); throw subError; } - + console.log("[Redis] Redis initialization completed successfully"); } catch (err) { console.error("[Redis] Initialization error:", err); diff --git a/server/utils/badges/getTimeInSeconds.js b/server/utils/badges/getTimeInSeconds.js new file mode 100644 index 0000000..4d38151 --- /dev/null +++ b/server/utils/badges/getTimeInSeconds.js @@ -0,0 +1,9 @@ +export const getTimeInSeconds = (time) => { + const parts = time.split(":").map(Number); + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } else if (parts.length === 2) { + return parts[0] * 60 + parts[1]; + } + return Infinity; +}; diff --git a/server/utils/badges/grantBadge.js b/server/utils/badges/grantBadge.js new file mode 100644 index 0000000..d709520 --- /dev/null +++ b/server/utils/badges/grantBadge.js @@ -0,0 +1,19 @@ +import { Ecosystem } from "../topiaInit.js"; + +export const grantBadge = async ({ credentials, visitor, visitorInventory, badgeName }) => { + 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) { + return new Error(error); + } +}; diff --git a/server/utils/badges/index.js b/server/utils/badges/index.js new file mode 100644 index 0000000..093aa42 --- /dev/null +++ b/server/utils/badges/index.js @@ -0,0 +1,3 @@ +export * from "./getTimeInSeconds.js"; +export * from "./grantBadge.js"; +export * from "./isNewHighScoreTop3.js"; diff --git a/server/utils/badges/isNewHighScoreTop3.js b/server/utils/badges/isNewHighScoreTop3.js new file mode 100644 index 0000000..bdf82f8 --- /dev/null +++ b/server/utils/badges/isNewHighScoreTop3.js @@ -0,0 +1,12 @@ +import { getTimeInSeconds } from "./getTimeInSeconds.js"; + +export const isNewHighScoreTop3 = (leaderboard, newHighScore) => { + const times = Object.values(leaderboard) + .map((entry) => entry.split("|")[1]) + .map(getTimeInSeconds); + + times.push(getTimeInSeconds(newHighScore)); + times.sort((a, b) => a - b); + + return times.slice(0, 3).includes(getTimeInSeconds(newHighScore)); +}; diff --git a/server/utils/checkpoints/finishLineEntered.js b/server/utils/checkpoints/finishLineEntered.js index b0c9e4a..4fcfefc 100644 --- a/server/utils/checkpoints/finishLineEntered.js +++ b/server/utils/checkpoints/finishLineEntered.js @@ -1,7 +1,15 @@ import { WorldActivityType } from "@rtsdk/topia"; -import { World, errorHandler, getVisitor, timeToValue, updateVisitorProgress } from "../index.js"; +import { + World, + errorHandler, + getVisitor, + grantBadge, + isNewHighScoreTop3, + timeToValue, + updateVisitorProgress, +} from "../index.js"; -export const finishLineEntered = async ({ credentials, currentElapsedTime }) => { +export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWrongCheckpointEntered }) => { try { const { assetId, displayName, profileId, sceneDropId, urlSlug } = credentials; @@ -9,7 +17,7 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime }) => await world.fetchDataObject(); const raceObject = world.dataObject?.[sceneDropId] || {}; - const { visitor, visitorProgress } = await getVisitor(credentials); + const { visitor, visitorProgress, visitorInventory } = await getVisitor(credentials); const { checkpoints, highScore } = visitorProgress; const allCheckpointsCompleted = raceObject.numberOfCheckpoints === Object.keys(checkpoints).length; @@ -43,6 +51,7 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime }) => }, visitor, visitorProgress, + hasCompletedRace: true, }); if (updateVisitorResult instanceof Error) throw updateVisitorResult; @@ -73,6 +82,75 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime }) => }), ); + // Grant Race Rookie badge if this is the visitor's first high score + if (!visitorProgress.highScore) { + grantBadge({ credentials, visitor, visitorInventory, badgeName: "Race Rookie" }).catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error granting Race Rookie badge", + }), + ); + } + + // Grant Top 3 Racer badge if newHighScore is in top 3 of leaderboard + const shouldGetTop3Badge = await isNewHighScoreTop3(raceObject.leaderboard, newHighScore); + if (shouldGetTop3Badge) { + grantBadge({ credentials, visitor, visitorInventory, badgeName: "Top 3 Racer" }).catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error granting Top 3 Racer badge", + }), + ); + } + + // Grant Speed Demon badge if newHighScore is less than 30 seconds + const [min, sec, mili] = currentElapsedTime.split(":").map(Number); + const totalSeconds = min * 60 + sec + mili / 100; + if (totalSeconds < 30) { + grantBadge({ credentials, visitor, visitorInventory, badgeName: "Speed Demon" }).catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error granting Speed Demon badge", + }), + ); + } + + // Grant Race Pro badge if visitor has completed 100 races + if (visitor.dataObject.racesCompleted + 1 >= 100) { + grantBadge({ credentials, visitor, visitorInventory, badgeName: "Race Pro" }).catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error granting Race Pro badge", + }), + ); + } + + // Grant Race Expert badge if visitor has completed 1000 races + if (visitor.dataObject.racesCompleted + 1 >= 1000) { + grantBadge({ credentials, visitor, visitorInventory, badgeName: "Race Expert" }).catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error granting Race Expert badge", + }), + ); + } + + // Grant Never Give Up badge if visitor completed the race after previously entering a wrong checkpoint + if (wasWrongCheckpointEntered) { + grantBadge({ credentials, visitor, visitorInventory, badgeName: "Never Give Up" }).catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error granting Never Give Up badge", + }), + ); + } + return; } catch (error) { return new Error(error); diff --git a/server/utils/index.js b/server/utils/index.js index ce851fa..0390f3b 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -1,3 +1,4 @@ +export * from "./badges/index.js"; export * from "./checkpoints/index.js"; export * from "./visitors/index.js"; export * from "./addNewRowToGoogleSheets.js"; diff --git a/server/utils/topiaInit.js b/server/utils/topiaInit.js index 9c43980..75010bb 100644 --- a/server/utils/topiaInit.js +++ b/server/utils/topiaInit.js @@ -1,7 +1,7 @@ import dotenv from "dotenv"; dotenv.config({ path: "../.env" }); -import { Topia, DroppedAssetFactory, VisitorFactory, WorldFactory } from "@rtsdk/topia"; +import { Topia, DroppedAssetFactory, EcosystemFactory, VisitorFactory, WorldFactory } from "@rtsdk/topia"; const config = { apiDomain: process.env.INSTANCE_DOMAIN || "api.topia.io", @@ -14,7 +14,8 @@ const config = { const myTopiaInstance = await new Topia(config); const DroppedAsset = new DroppedAssetFactory(myTopiaInstance); +const Ecosystem = new EcosystemFactory(myTopiaInstance); const Visitor = new VisitorFactory(myTopiaInstance); const World = new WorldFactory(myTopiaInstance); -export { DroppedAsset, Visitor, World }; +export { DroppedAsset, Ecosystem, Visitor, World }; diff --git a/server/utils/visitors/getVisitor.js b/server/utils/visitors/getVisitor.js index 924d822..d3ce9f3 100644 --- a/server/utils/visitors/getVisitor.js +++ b/server/utils/visitors/getVisitor.js @@ -44,7 +44,20 @@ export const getVisitor = async (credentials, shouldGetVisitorDetails = false) = await visitor.fetchDataObject(); - return { visitor, visitorProgress: visitor.dataObject?.[`${urlSlug}-${sceneDropId}`] }; + await visitor.fetchInventoryItems(); + let visitorInventory = {}; + + for (const item of visitor.inventoryItems || []) { + const { id, name = "", image_url } = item; + + visitorInventory[name] = { + id, + icon: image_url, + name, + }; + } + + return { visitor, visitorProgress: visitor.dataObject?.[`${urlSlug}-${sceneDropId}`], visitorInventory }; } catch (error) { return new Error(error); } diff --git a/server/utils/visitors/updateVisitorProgress.js b/server/utils/visitors/updateVisitorProgress.js index c4b20e3..e1dc41a 100644 --- a/server/utils/visitors/updateVisitorProgress.js +++ b/server/utils/visitors/updateVisitorProgress.js @@ -4,12 +4,17 @@ export const updateVisitorProgress = async ({ updatedProgress = {}, visitor, visitorProgress = {}, + hasCompletedRace = false, }) => { try { const { urlSlug, sceneDropId } = credentials; + let racesCompleted = visitor.dataObject.racesCompleted || 0; + if (hasCompletedRace) racesCompleted += 1; + await visitor.updateDataObject( { + racesCompleted, [`${urlSlug}-${sceneDropId}`]: { ...visitorProgress, ...updatedProgress, From 4a0b4449949e31c426aa468968c442da7e5752e1 Mon Sep 17 00:00:00 2001 From: Lina Date: Wed, 17 Dec 2025 10:47:07 -0800 Subject: [PATCH 09/20] clean up --- .../badges/{grantBadge.js => awardBadge.js} | 2 +- server/utils/badges/index.js | 2 +- server/utils/checkpoints/finishLineEntered.js | 192 ++++++++++-------- 3 files changed, 114 insertions(+), 82 deletions(-) rename server/utils/badges/{grantBadge.js => awardBadge.js} (90%) diff --git a/server/utils/badges/grantBadge.js b/server/utils/badges/awardBadge.js similarity index 90% rename from server/utils/badges/grantBadge.js rename to server/utils/badges/awardBadge.js index d709520..8fa9097 100644 --- a/server/utils/badges/grantBadge.js +++ b/server/utils/badges/awardBadge.js @@ -1,6 +1,6 @@ import { Ecosystem } from "../topiaInit.js"; -export const grantBadge = async ({ credentials, visitor, visitorInventory, badgeName }) => { +export const awardBadge = async ({ credentials, visitor, visitorInventory, badgeName }) => { try { if (visitorInventory[badgeName]) return { success: true }; diff --git a/server/utils/badges/index.js b/server/utils/badges/index.js index 093aa42..78da136 100644 --- a/server/utils/badges/index.js +++ b/server/utils/badges/index.js @@ -1,3 +1,3 @@ export * from "./getTimeInSeconds.js"; -export * from "./grantBadge.js"; +export * from "./awardBadge.js"; export * from "./isNewHighScoreTop3.js"; diff --git a/server/utils/checkpoints/finishLineEntered.js b/server/utils/checkpoints/finishLineEntered.js index 4fcfefc..ebe769e 100644 --- a/server/utils/checkpoints/finishLineEntered.js +++ b/server/utils/checkpoints/finishLineEntered.js @@ -3,7 +3,7 @@ import { World, errorHandler, getVisitor, - grantBadge, + awardBadge, isNewHighScoreTop3, timeToValue, updateVisitorProgress, @@ -13,6 +13,8 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr try { const { assetId, displayName, profileId, sceneDropId, urlSlug } = credentials; + const promises = []; + const world = World.create(urlSlug, { credentials }); await world.fetchDataObject(); const raceObject = world.dataObject?.[sceneDropId] || {}; @@ -27,12 +29,14 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr !highScore || timeToValue(currentElapsedTime) < timeToValue(highScore) ? currentElapsedTime : highScore; if (newHighScore !== highScore) { - world.triggerActivity({ type: WorldActivityType.GAME_HIGH_SCORE, assetId }).catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error triggering world activity", - }), + promises.push( + world.triggerActivity({ type: WorldActivityType.GAME_HIGH_SCORE, assetId }).catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error triggering world activity", + }), + ), ); } @@ -55,102 +59,130 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr }); if (updateVisitorResult instanceof Error) throw updateVisitorResult; - visitor - .fireToast({ - groupId: "race", - title: "🏁 Finish", - text: `You finished the race! Your time: ${currentElapsedTime}`, - }) - .catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error firing toast", - }), - ); - - visitor - .triggerParticle({ - name: "trophy_float", - duration: 3, - }) - .catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error triggering particle effects", - }), - ); - - // Grant Race Rookie badge if this is the visitor's first high score + promises.push( + visitor + .fireToast({ + groupId: "race", + title: "🏁 Finish", + text: `You finished the race! Your time: ${currentElapsedTime}`, + }) + .catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error firing toast", + }), + ), + ); + + promises.push( + visitor + .triggerParticle({ + name: "trophy_float", + duration: 3, + }) + .catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error triggering particle effects", + }), + ), + ); + + // Award Race Rookie badge if this is the visitor's first high score if (!visitorProgress.highScore) { - grantBadge({ credentials, visitor, visitorInventory, badgeName: "Race Rookie" }).catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error granting Race Rookie badge", - }), + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Race Rookie" }).catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Race Rookie badge", + }), + ), ); } - // Grant Top 3 Racer badge if newHighScore is in top 3 of leaderboard + // Award Top 3 Racer badge if newHighScore is in top 3 of leaderboard const shouldGetTop3Badge = await isNewHighScoreTop3(raceObject.leaderboard, newHighScore); if (shouldGetTop3Badge) { - grantBadge({ credentials, visitor, visitorInventory, badgeName: "Top 3 Racer" }).catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error granting Top 3 Racer badge", - }), + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Top 3 Racer" }).catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Top 3 Racer badge", + }), + ), ); } - // Grant Speed Demon badge if newHighScore is less than 30 seconds + // Award Speed Demon badge if newHighScore is less than 30 seconds or Slow & Steady badge if more than 2 minutes const [min, sec, mili] = currentElapsedTime.split(":").map(Number); const totalSeconds = min * 60 + sec + mili / 100; if (totalSeconds < 30) { - grantBadge({ credentials, visitor, visitorInventory, badgeName: "Speed Demon" }).catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error granting Speed Demon badge", - }), + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Speed Demon" }).catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Speed Demon badge", + }), + ), ); - } - - // Grant Race Pro badge if visitor has completed 100 races - if (visitor.dataObject.racesCompleted + 1 >= 100) { - grantBadge({ credentials, visitor, visitorInventory, badgeName: "Race Pro" }).catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error granting Race Pro badge", - }), + } else if (totalSeconds > 120) { + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Slow & Steady" }).catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Slow & Steady badge", + }), + ), ); } - // Grant Race Expert badge if visitor has completed 1000 races - if (visitor.dataObject.racesCompleted + 1 >= 1000) { - grantBadge({ credentials, visitor, visitorInventory, badgeName: "Race Expert" }).catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error granting Race Expert badge", - }), + // Award Race Pro badge if visitor has completed 100 races or Race Expert badge if visitor has completed 1000 races + if (visitor.dataObject.racesCompleted + 1 === 100) { + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Race Pro" }).catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Race Pro badge", + }), + ), + ); + } else if (visitor.dataObject.racesCompleted + 1 === 1000) { + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Race Expert" }).catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Race Expert badge", + }), + ), ); } - // Grant Never Give Up badge if visitor completed the race after previously entering a wrong checkpoint + // Award Never Give Up badge if visitor completed the race after previously entering a wrong checkpoint if (wasWrongCheckpointEntered) { - grantBadge({ credentials, visitor, visitorInventory, badgeName: "Never Give Up" }).catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error granting Never Give Up badge", - }), + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Never Give Up" }).catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Never Give Up badge", + }), + ), ); } + const results = await Promise.allSettled(promises); + results.forEach((result) => { + if (result.status === "rejected") console.error(result.reason); + }); + return; } catch (error) { return new Error(error); From 13d63841bb625ad80405c00eecdcd600a4cba47d Mon Sep 17 00:00:00 2001 From: Lina Date: Wed, 17 Dec 2025 15:13:32 -0800 Subject: [PATCH 10/20] badges UI --- client/index.html | 12 ++- client/src/App.jsx | 6 +- client/src/components/Admin/AdminGear.jsx | 15 --- .../src/components/Admin/AdminIconButton.jsx | 16 +++ client/src/components/Admin/AdminView.jsx | 100 +++++++----------- client/src/components/Admin/BackArrow.jsx | 15 --- .../components/BadgesScreen/BadgesScreen.jsx | 40 +++++++ .../components/Leaderboard/Leaderboard.jsx | 64 +++++++++++ .../Leaderboard/LeaderboardScreen.jsx | 22 ++++ .../NewGameScreen/NewGameScreen.jsx | 49 ++++----- .../OnYourMarkScreen/OnYourMarkScreen.jsx | 2 +- .../RaceCompletedScreen.jsx | 33 +++--- .../RaceInProgressScreen/Checkpoint.jsx | 6 +- .../RaceInProgressScreen.jsx | 25 ++--- .../components/ResetGame/ResetGameButton.jsx | 15 --- .../components/ResetGame/ResetGameModal.jsx | 63 ----------- client/src/components/Shared/BackButton.jsx | 15 +++ .../components/Shared/ConfirmationModal.jsx | 43 ++++++++ client/src/components/Shared/Footer.jsx | 10 +- client/src/components/Shared/Loading.jsx | 3 +- .../src/components/Shared/PageContainer.jsx | 36 +++++++ client/src/components/Shared/Tabs.jsx | 30 ++++++ .../SwitchRace/SwitchRaceTrackButton.jsx | 4 +- .../SwitchRace/SwitchRaceTrackModal.jsx | 8 +- .../SwitchRace/SwitchTrackScreen.jsx | 69 ++++++++++++ client/src/components/index.js | 19 ++++ client/src/context/reducer.js | 17 +++ client/src/context/types.js | 3 + client/src/index.scss | 99 +++++++++++++---- client/src/pages/Home.jsx | 56 ++++++---- client/src/pages/Leaderboard.jsx | 94 ---------------- client/src/pages/Leaderboard.scss | 19 ---- client/src/pages/LeaderboardPage.jsx | 38 +++++++ client/src/utils/loadGameState.js | 37 +++++-- server/controllers/handleCompleteRace.js | 4 +- server/controllers/handleLoadGameState.js | 8 +- server/controllers/handleResetGame.js | 2 +- server/utils/badges/getInventoryItems.js | 34 ++++++ server/utils/badges/index.js | 3 +- 39 files changed, 711 insertions(+), 423 deletions(-) delete mode 100644 client/src/components/Admin/AdminGear.jsx create mode 100644 client/src/components/Admin/AdminIconButton.jsx delete mode 100644 client/src/components/Admin/BackArrow.jsx create mode 100644 client/src/components/BadgesScreen/BadgesScreen.jsx create mode 100644 client/src/components/Leaderboard/Leaderboard.jsx create mode 100644 client/src/components/Leaderboard/LeaderboardScreen.jsx delete mode 100644 client/src/components/ResetGame/ResetGameButton.jsx delete mode 100644 client/src/components/ResetGame/ResetGameModal.jsx create mode 100644 client/src/components/Shared/BackButton.jsx create mode 100644 client/src/components/Shared/ConfirmationModal.jsx create mode 100644 client/src/components/Shared/PageContainer.jsx create mode 100644 client/src/components/Shared/Tabs.jsx create mode 100644 client/src/components/SwitchRace/SwitchTrackScreen.jsx create mode 100644 client/src/components/index.js delete mode 100644 client/src/pages/Leaderboard.jsx delete mode 100644 client/src/pages/Leaderboard.scss create mode 100644 client/src/pages/LeaderboardPage.jsx create mode 100644 server/utils/badges/getInventoryItems.js diff --git a/client/index.html b/client/index.html index 85afe3f..ccc3268 100644 --- a/client/index.html +++ b/client/index.html @@ -5,9 +5,15 @@ - - - + + + Race diff --git a/client/src/App.jsx b/client/src/App.jsx index 3f0d277..c4963fe 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,9 +1,9 @@ import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { Route, Routes, useSearchParams } from "react-router-dom"; -// pagrs +// pages import Home from "@pages/Home"; -import Leaderboard from "@pages/Leaderboard"; +import LeaderboardPage from "@pages/LeaderboardPage"; import Error from "@pages/Error"; // context @@ -89,7 +89,7 @@ const App = () => { return ( } /> - } /> + } /> } /> ); diff --git a/client/src/components/Admin/AdminGear.jsx b/client/src/components/Admin/AdminGear.jsx deleted file mode 100644 index e6f274a..0000000 --- a/client/src/components/Admin/AdminGear.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import PropTypes from "prop-types"; - -function AdminGear({ setShowSettings }) { - return ( -
setShowSettings(true)}> - -
- ); -} - -AdminGear.propTypes = { - setShowSettings: PropTypes.func, -}; - -export default AdminGear; diff --git a/client/src/components/Admin/AdminIconButton.jsx b/client/src/components/Admin/AdminIconButton.jsx new file mode 100644 index 0000000..5d033eb --- /dev/null +++ b/client/src/components/Admin/AdminIconButton.jsx @@ -0,0 +1,16 @@ +import PropTypes from "prop-types"; + +export const AdminIconButton = ({ setShowSettings, showSettings }) => { + return ( +
setShowSettings(showSettings)}> + {showSettings ? "←" : "⛭"} +
+ ); +}; + +AdminIconButton.propTypes = { + setShowSettings: PropTypes.func, + showSettings: PropTypes.bool, +}; + +export default AdminIconButton; diff --git a/client/src/components/Admin/AdminView.jsx b/client/src/components/Admin/AdminView.jsx index ec10d9f..662810e 100644 --- a/client/src/components/Admin/AdminView.jsx +++ b/client/src/components/Admin/AdminView.jsx @@ -1,82 +1,62 @@ -import { useState, useContext } from "react"; -import PropTypes from "prop-types"; +import { useContext, useState } from "react"; // components -import BackArrow from "./BackArrow"; -import ResetGameButton from "../ResetGame/ResetGameButton"; -import ResetGameModal from "../ResetGame/ResetGameModal"; -import SwitchRaceTrackModal from "../SwitchRace/SwitchRaceTrackModal"; -import Footer from "../Shared/Footer"; +import { ConfirmationModal, Footer } from "@components"; // context -import { GlobalStateContext } from "@context/GlobalContext"; +import { GlobalDispatchContext } from "@context/GlobalContext"; +import { RESET_GAME, SCREEN_MANAGER, SET_ERROR } from "@context/types"; + +// utils +import { backendAPI, getErrorMessage } from "@utils"; + +export const AdminView = () => { + const dispatch = useContext(GlobalDispatchContext); -function AdminView({ setShowSettings }) { - const { tracks } = useContext(GlobalStateContext); - const [message, setMessage] = useState(false); const [showResetGameModal, setShowResetGameModal] = useState(false); - const [showTrackModal, setShowTrackModal] = useState(false); - const [selectedTrack, setSelectedTrack] = useState(null); function handleToggleShowResetGameModal() { setShowResetGameModal(!showResetGameModal); } - function handleToggleShowTrackModal(track) { - setSelectedTrack(track); - setShowTrackModal(!showTrackModal); - } - - function handleTrackSelect(track) { - setSelectedTrack(track.id); - setShowTrackModal(true); - } + const handleResetGame = async () => { + await backendAPI + .post("/race/reset-game") + .then(() => { + dispatch({ type: RESET_GAME }); + dispatch({ type: SCREEN_MANAGER.SHOW_HOME_SCREEN }); + }) + .catch((error) => { + dispatch({ + type: SET_ERROR, + payload: { error: getErrorMessage("resetting", error) }, + }); + }) + .finally(() => { + handleToggleShowResetGameModal(); + }); + }; return ( <> +

Settings

+ +
+ +
+ {showResetGameModal && ( - - )} - {showTrackModal && selectedTrack && ( - track.id === selectedTrack)} - handleToggleShowModal={() => handleToggleShowTrackModal(null)} - setMessage={setMessage} + )} - -
-
-

Settings

-

Select a track to change the current one.

-

{message}

-
- {tracks?.map((track) => ( -
handleTrackSelect(track)} - > -
-
- {track.name} -
-
-

{track.name}

-
-
-
- ))} -
-
- -
); -} - -AdminView.propTypes = { - setShowSettings: PropTypes.func, }; export default AdminView; diff --git a/client/src/components/Admin/BackArrow.jsx b/client/src/components/Admin/BackArrow.jsx deleted file mode 100644 index ab52067..0000000 --- a/client/src/components/Admin/BackArrow.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import PropTypes from "prop-types"; - -function BackArrow({ setShowSettings }) { - return ( -
setShowSettings(false)}> - -
- ); -} - -BackArrow.propTypes = { - setShowSettings: PropTypes.func, -}; - -export default BackArrow; diff --git a/client/src/components/BadgesScreen/BadgesScreen.jsx b/client/src/components/BadgesScreen/BadgesScreen.jsx new file mode 100644 index 0000000..e6f371f --- /dev/null +++ b/client/src/components/BadgesScreen/BadgesScreen.jsx @@ -0,0 +1,40 @@ +import { useContext } from "react"; + +// components +import { BackButton } from "@components"; + +// context +import { GlobalDispatchContext, GlobalStateContext } from "@context/GlobalContext"; +import { SCREEN_MANAGER } from "@context/types"; + +export const BadgesScreen = () => { + const dispatch = useContext(GlobalDispatchContext); + const { badges, visitorInventory } = useContext(GlobalStateContext); + + return ( + <> + dispatch({ type: SCREEN_MANAGER.SHOW_HOME_SCREEN })} /> + +
+

+ Badges +

+ +
+ {Object.values(badges).map((badge) => { + const hasBadge = visitorInventory && Object.keys(visitorInventory).includes(badge.name); + const style = hasBadge ? {} : { filter: "grayscale(1)" }; + return ( +
+ {badge.name} + {badge.name} +
+ ); + })} +
+
+ + ); +}; + +export default BadgesScreen; diff --git a/client/src/components/Leaderboard/Leaderboard.jsx b/client/src/components/Leaderboard/Leaderboard.jsx new file mode 100644 index 0000000..f82c80e --- /dev/null +++ b/client/src/components/Leaderboard/Leaderboard.jsx @@ -0,0 +1,64 @@ +import { useContext } from "react"; + +// components +import { Footer } from "@components"; + +// context +import { GlobalDispatchContext, GlobalStateContext } from "@context/GlobalContext"; +import { SCREEN_MANAGER } from "@context/types"; + +export const Leaderboard = () => { + const dispatch = useContext(GlobalDispatchContext); + const { leaderboard, highScore } = useContext(GlobalStateContext); + + return ( + <> +
+
🏆
+

+ Leaderboard +

+
+

Personal Best

+

{highScore || "No highScore available"}

+
+
+ {leaderboard?.length > 0 ? ( + + + + + + + + + + {leaderboard?.map((item, index) => { + return ( + + + + + + ); + })} + +
+ Name + Time
{index + 1}{item.displayName}{item.highScore}
+ ) : ( +

There are no race finishes yet.

+ )} +
+
+ +
+ +
+ + ); +}; + +export default Leaderboard; diff --git a/client/src/components/Leaderboard/LeaderboardScreen.jsx b/client/src/components/Leaderboard/LeaderboardScreen.jsx new file mode 100644 index 0000000..c8f81b8 --- /dev/null +++ b/client/src/components/Leaderboard/LeaderboardScreen.jsx @@ -0,0 +1,22 @@ +import { useContext } from "react"; + +// components +import { BackButton, Leaderboard } from "@components"; + +// context +import { GlobalDispatchContext } from "@context/GlobalContext"; +import { SCREEN_MANAGER } from "@context/types"; + +export const LeaderboardScreen = () => { + const dispatch = useContext(GlobalDispatchContext); + + return ( + <> + dispatch({ type: SCREEN_MANAGER.SHOW_HOME_SCREEN })} /> + + + + ); +}; + +export default LeaderboardScreen; diff --git a/client/src/components/NewGameScreen/NewGameScreen.jsx b/client/src/components/NewGameScreen/NewGameScreen.jsx index 35fad4a..1b41bc8 100644 --- a/client/src/components/NewGameScreen/NewGameScreen.jsx +++ b/client/src/components/NewGameScreen/NewGameScreen.jsx @@ -2,51 +2,42 @@ import { useContext } from "react"; // components import racingMap from "../../assets/racingMap.png"; -import Footer from "../Shared/Footer"; +import { Footer, Tabs } from "@components"; // context import { GlobalDispatchContext } from "@context/GlobalContext"; import { SCREEN_MANAGER } from "@context/types"; -const NewGameScreen = () => { +export const NewGameScreen = () => { const dispatch = useContext(GlobalDispatchContext); + const goToSwitchTrackScreen = () => { + dispatch({ type: SCREEN_MANAGER.SHOW_SWITCH_TRACK_SCREEN }); + }; + const startRace = () => { dispatch({ type: SCREEN_MANAGER.SHOW_ON_YOUR_MARK_SCREEN }); }; - const Instructions = () => ( - <> -

🏎️ Welcome to the Race!

-
-

How to play:

-
    -
  1. - Click Start Race to begin. -
  2. -
  3. 🏁 Run through all checkpoints in the correct order to complete the race!
  4. -
- -

Important rules:

-
    -
  • - Time starts when you click Start Race. -
  • -
  • Check your rank by clicking the 🏆 leaderboard.
  • -
-
- - ); - return ( <> -
+
+ racing map +
+ How to Play +
    +
  1. Click Start Race to begin.
  2. +
  3. Run through all checkpoints in the correct order to complete the race!
  4. +
+
- +
- +
diff --git a/client/src/components/OnYourMarkScreen/OnYourMarkScreen.jsx b/client/src/components/OnYourMarkScreen/OnYourMarkScreen.jsx index 52323e1..d93e9c3 100644 --- a/client/src/components/OnYourMarkScreen/OnYourMarkScreen.jsx +++ b/client/src/components/OnYourMarkScreen/OnYourMarkScreen.jsx @@ -7,7 +7,7 @@ import { GlobalStateContext, GlobalDispatchContext } from "@context/GlobalContex // utils import { startRace } from "@utils"; -const OnYourMarkScreen = () => { +export const OnYourMarkScreen = () => { const navigate = useNavigate(); const dispatch = useContext(GlobalDispatchContext); const { isAdmin } = useContext(GlobalStateContext); diff --git a/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx b/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx index e2323bd..0b7239a 100644 --- a/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx +++ b/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx @@ -1,15 +1,13 @@ import { useContext } from "react"; -import { Link } from "react-router-dom"; // components -import Footer from "@components/Shared/Footer"; +import { Footer, Tabs } from "@components"; // context import { GlobalStateContext, GlobalDispatchContext } from "@context/GlobalContext"; import { SCREEN_MANAGER } from "@context/types"; -const RaceCompletedScreen = () => { - const queryParams = new URLSearchParams(location.search); +export const RaceCompletedScreen = () => { const dispatch = useContext(GlobalDispatchContext); const { elapsedTime } = useContext(GlobalStateContext); @@ -19,18 +17,25 @@ const RaceCompletedScreen = () => { return ( <> -
-

🏆 Congratulations!

-

You have successfully completed the race.

-

Elapsed Time: {elapsedTime}

+
+ + +
+
+

+ Congratulations! +

+
🏁
+

Your Time

+

+ {elapsedTime} +

+
+
+
- - - -
diff --git a/client/src/components/RaceInProgressScreen/Checkpoint.jsx b/client/src/components/RaceInProgressScreen/Checkpoint.jsx index f0ed29f..e720829 100644 --- a/client/src/components/RaceInProgressScreen/Checkpoint.jsx +++ b/client/src/components/RaceInProgressScreen/Checkpoint.jsx @@ -2,9 +2,9 @@ import PropTypes from "prop-types"; export const Checkpoint = ({ number, completed }) => { return ( -
- {completed ? "🟢" : "⚪"} - {number === "Finish" ? "Finish" : `Checkpoint ${number}`} +
+ + {number === "Finish" ? "Finish" : `Checkpoint ${number}`}
); }; diff --git a/client/src/components/RaceInProgressScreen/RaceInProgressScreen.jsx b/client/src/components/RaceInProgressScreen/RaceInProgressScreen.jsx index 92e1a96..bad165f 100644 --- a/client/src/components/RaceInProgressScreen/RaceInProgressScreen.jsx +++ b/client/src/components/RaceInProgressScreen/RaceInProgressScreen.jsx @@ -2,9 +2,7 @@ import { useState, useContext, useEffect, useRef } from "react"; import { useSearchParams } from "react-router-dom"; // components -import Checkpoint from "./Checkpoint"; -import Footer from "@components/Shared/Footer"; -import Loading from "@components/Shared/Loading"; +import { Checkpoint, Footer, Loading } from "@components"; // context import { GlobalStateContext, GlobalDispatchContext } from "@context/GlobalContext"; @@ -12,7 +10,7 @@ import { GlobalStateContext, GlobalDispatchContext } from "@context/GlobalContex // utils import { cancelRace, completeRace } from "@utils"; -const RaceInProgressScreen = () => { +export const RaceInProgressScreen = () => { const positiveAudioRef = useRef(null); const negativeAudioRef = useRef(null); const successAudioRef = useRef(null); @@ -175,24 +173,19 @@ const RaceInProgressScreen = () => { if (elapsedTime == "00:00") return ; return ( -
-
-
⌛ {elapsedTime}
-
-

Race in progress!

-
-

- Run! -

-
-
+
+

+ {elapsedTime} +

+
{checkpoints?.map((checkpoint) => ( ))}
+
-
diff --git a/client/src/components/ResetGame/ResetGameButton.jsx b/client/src/components/ResetGame/ResetGameButton.jsx deleted file mode 100644 index e00ee4d..0000000 --- a/client/src/components/ResetGame/ResetGameButton.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import PropTypes from "prop-types"; - -function ResetGameButton({ handleToggleShowModal }) { - return ( - - ); -} - -ResetGameButton.propTypes = { - handleToggleShowModal: PropTypes.func, -}; - -export default ResetGameButton; diff --git a/client/src/components/ResetGame/ResetGameModal.jsx b/client/src/components/ResetGame/ResetGameModal.jsx deleted file mode 100644 index 36cbbdf..0000000 --- a/client/src/components/ResetGame/ResetGameModal.jsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useState, useContext } from "react"; -import PropTypes from "prop-types"; - -// context -import { GlobalDispatchContext } from "@context/GlobalContext"; -import { RESET_GAME, SCREEN_MANAGER, SET_ERROR } from "@context/types"; - -// utils -import { backendAPI, getErrorMessage } from "@utils"; - -function ResetGameModal({ handleToggleShowModal, setMessage }) { - const dispatch = useContext(GlobalDispatchContext); - const [areAllButtonsDisabled, setAreAllButtonsDisabled] = useState(false); - - async function handleResetGame() { - try { - setAreAllButtonsDisabled(true); - const result = await backendAPI.post("/race/reset-game"); - if (result.data.success) { - dispatch({ type: RESET_GAME }); - dispatch({ type: SCREEN_MANAGER.SHOW_HOME_SCREEN }); - setMessage("The game and leaderboard have been reset successfully."); - } - } catch (error) { - dispatch({ - type: SET_ERROR, - payload: { error: getErrorMessage("resetting", error) }, - }); - } finally { - setAreAllButtonsDisabled(false); - handleToggleShowModal(); - } - } - - return ( -
-
-

Reset Game

-

If you reset the game, the leaderboard will be removed. Are you sure that you would like to continue?

-
- - -
-
-
- ); -} - -ResetGameModal.propTypes = { - handleToggleShowModal: PropTypes.func, - setMessage: PropTypes.func, -}; - -export default ResetGameModal; diff --git a/client/src/components/Shared/BackButton.jsx b/client/src/components/Shared/BackButton.jsx new file mode 100644 index 0000000..4054a47 --- /dev/null +++ b/client/src/components/Shared/BackButton.jsx @@ -0,0 +1,15 @@ +import PropTypes from "prop-types"; + +export const BackButton = ({ onClick }) => { + return ( +
+ ← +
+ ); +}; + +BackButton.propTypes = { + onClick: PropTypes.func.isRequired, +}; + +export default BackButton; diff --git a/client/src/components/Shared/ConfirmationModal.jsx b/client/src/components/Shared/ConfirmationModal.jsx new file mode 100644 index 0000000..2ee9f38 --- /dev/null +++ b/client/src/components/Shared/ConfirmationModal.jsx @@ -0,0 +1,43 @@ +import { useState } from "react"; +import PropTypes from "prop-types"; + +export const ConfirmationModal = ({ title, message, handleOnConfirm, handleToggleShowConfirmationModal }) => { + const [areButtonsDisabled, setAreButtonsDisabled] = useState(false); + + const onConfirm = async () => { + setAreButtonsDisabled(true); + await handleOnConfirm(); + handleToggleShowConfirmationModal(); + }; + + return ( +
+
+

{title}

+

{message}

+
+ + +
+
+
+ ); +}; + +ConfirmationModal.propTypes = { + title: PropTypes.string, + message: PropTypes.string, + handleOnConfirm: PropTypes.func, + handleToggleShowConfirmationModal: PropTypes.func, +}; + +export default ConfirmationModal; diff --git a/client/src/components/Shared/Footer.jsx b/client/src/components/Shared/Footer.jsx index f641277..eb0db3c 100644 --- a/client/src/components/Shared/Footer.jsx +++ b/client/src/components/Shared/Footer.jsx @@ -1,12 +1,8 @@ import PropTypes from "prop-types"; -function Footer({ children }) { - return ( -
-
{children}
-
- ); -} +export const Footer = ({ children }) => { + return
{children}
; +}; Footer.propTypes = { children: PropTypes.node, diff --git a/client/src/components/Shared/Loading.jsx b/client/src/components/Shared/Loading.jsx index f679112..abf93da 100644 --- a/client/src/components/Shared/Loading.jsx +++ b/client/src/components/Shared/Loading.jsx @@ -2,8 +2,9 @@ export const Loading = () => { return (
Loading
); diff --git a/client/src/components/Shared/PageContainer.jsx b/client/src/components/Shared/PageContainer.jsx new file mode 100644 index 0000000..d496276 --- /dev/null +++ b/client/src/components/Shared/PageContainer.jsx @@ -0,0 +1,36 @@ +import { useContext, useState } from "react"; +import PropTypes from "prop-types"; + +// components +import { AdminIconButton, Loading, AdminView } from "@/components"; + +// context +import { GlobalStateContext } from "@context/GlobalContext"; + +export const PageContainer = ({ children, isLoading }) => { + const { error, isAdmin } = useContext(GlobalStateContext); + + const [showSettings, setShowSettings] = useState(false); + + if (isLoading) return ; + + return ( + <> +
+
+ {isAdmin && ( + setShowSettings(!showSettings)} showSettings={showSettings} /> + )} + {showSettings ? : children} + {error &&

{error}

} +
+ + ); +}; + +PageContainer.propTypes = { + children: PropTypes.node, + isLoading: PropTypes.bool, +}; + +export default PageContainer; diff --git a/client/src/components/Shared/Tabs.jsx b/client/src/components/Shared/Tabs.jsx new file mode 100644 index 0000000..03a237a --- /dev/null +++ b/client/src/components/Shared/Tabs.jsx @@ -0,0 +1,30 @@ +import { useContext } from "react"; + +// context +import { GlobalDispatchContext } from "@context/GlobalContext"; +import { SCREEN_MANAGER } from "@context/types"; + +export const Tabs = () => { + const dispatch = useContext(GlobalDispatchContext); + + const goToLeaderboardScreen = () => { + dispatch({ type: SCREEN_MANAGER.SHOW_LEADERBOARD_SCREEN }); + }; + + const goToBadgesScreen = () => { + dispatch({ type: SCREEN_MANAGER.SHOW_BADGES_SCREEN }); + }; + + return ( +
+ + +
+ ); +}; + +export default Tabs; diff --git a/client/src/components/SwitchRace/SwitchRaceTrackButton.jsx b/client/src/components/SwitchRace/SwitchRaceTrackButton.jsx index 89cbea8..b26aa54 100644 --- a/client/src/components/SwitchRace/SwitchRaceTrackButton.jsx +++ b/client/src/components/SwitchRace/SwitchRaceTrackButton.jsx @@ -1,13 +1,13 @@ import PropTypes from "prop-types"; -function SwitchRaceTrackButton({ track, handleToggleShowTrackModal }) { +export const SwitchRaceTrackButton = ({ track, handleToggleShowTrackModal }) => { return (
handleToggleShowTrackModal(track)}> {track.name}

{track.name}

); -} +}; SwitchRaceTrackButton.propTypes = { handleToggleShowTrackModal: PropTypes.func, diff --git a/client/src/components/SwitchRace/SwitchRaceTrackModal.jsx b/client/src/components/SwitchRace/SwitchRaceTrackModal.jsx index 164304d..78518fc 100644 --- a/client/src/components/SwitchRace/SwitchRaceTrackModal.jsx +++ b/client/src/components/SwitchRace/SwitchRaceTrackModal.jsx @@ -2,7 +2,7 @@ import { useState } from "react"; import PropTypes from "prop-types"; import { backendAPI } from "@utils/backendAPI"; -function TrackSwitcherModal({ track, handleToggleShowModal, setMessage }) { +export const SwitchRaceTrackModal = ({ track, handleToggleShowModal, setMessage }) => { const [areAllButtonsDisabled, setAreAllButtonsDisabled] = useState(false); async function handleSwitchTrack() { @@ -45,9 +45,9 @@ function TrackSwitcherModal({ track, handleToggleShowModal, setMessage }) {
); -} +}; -TrackSwitcherModal.propTypes = { +SwitchRaceTrackModal.propTypes = { handleToggleShowModal: PropTypes.func, setMessage: PropTypes.func, track: { @@ -56,4 +56,4 @@ TrackSwitcherModal.propTypes = { }, }; -export default TrackSwitcherModal; +export default SwitchRaceTrackModal; diff --git a/client/src/components/SwitchRace/SwitchTrackScreen.jsx b/client/src/components/SwitchRace/SwitchTrackScreen.jsx new file mode 100644 index 0000000..191e6f3 --- /dev/null +++ b/client/src/components/SwitchRace/SwitchTrackScreen.jsx @@ -0,0 +1,69 @@ +import { useState, useContext } from "react"; + +// components +import { BackButton, Footer } from "@components"; + +// context +import { GlobalDispatchContext, GlobalStateContext } from "@context/GlobalContext"; +import { SET_ERROR, SCREEN_MANAGER } from "@context/types"; + +// utils +import { backendAPI, getErrorMessage } from "@utils"; + +export const SwitchTrackScreen = () => { + const dispatch = useContext(GlobalDispatchContext); + const { tracks } = useContext(GlobalStateContext); + + const [selectedTrack, setSelectedTrack] = useState(null); + const [areAllButtonsDisabled, setAreAllButtonsDisabled] = useState(false); + + const updateTrack = async () => { + setAreAllButtonsDisabled(true); + + await backendAPI + .post(`/race/switch-track?trackSceneId=${selectedTrack.sceneId}`) + .catch((error) => { + dispatch({ + type: SET_ERROR, + payload: { error: getErrorMessage("resetting", error) }, + }); + }) + .finally(() => { + setAreAllButtonsDisabled(false); + }); + }; + + return ( + <> + dispatch({ type: SCREEN_MANAGER.SHOW_HOME_SCREEN })} /> + +
+

Choose New Track

+

Updates will reflect for everyone

+
+ +
+ {tracks?.map((track) => ( + + ))} +
+ +
+ +
+ + ); +}; + +export default SwitchTrackScreen; diff --git a/client/src/components/index.js b/client/src/components/index.js new file mode 100644 index 0000000..6c09052 --- /dev/null +++ b/client/src/components/index.js @@ -0,0 +1,19 @@ +export * from "./Admin/AdminIconButton.jsx"; +export * from "./Admin/AdminView.jsx"; +export * from "./Leaderboard/Leaderboard.jsx"; +export * from "./Leaderboard/LeaderboardScreen.jsx"; +export * from "./BadgesScreen/BadgesScreen.jsx"; +export * from "./NewGameScreen/NewGameScreen.jsx"; +export * from "./OnYourMarkScreen/OnYourMarkScreen.jsx"; +export * from "./RaceCompletedScreen/RaceCompletedScreen.jsx"; +export * from "./RaceInProgressScreen/Checkpoint.jsx"; +export * from "./RaceInProgressScreen/RaceInProgressScreen.jsx"; +export * from "./Shared/BackButton.jsx"; +export * from "./Shared/ConfirmationModal.jsx"; +export * from "./Shared/Footer.jsx"; +export * from "./Shared/Loading.jsx"; +export * from "./Shared/PageContainer.jsx"; +export * from "./Shared/Tabs.jsx"; +export * from "./SwitchRace/SwitchRaceTrackButton.jsx"; +export * from "./SwitchRace/SwitchRaceTrackModal.jsx"; +export * from "./SwitchRace/SwitchTrackScreen.jsx"; diff --git a/client/src/context/reducer.js b/client/src/context/reducer.js index aff78b9..0cb506c 100644 --- a/client/src/context/reducer.js +++ b/client/src/context/reducer.js @@ -23,6 +23,21 @@ const globalReducer = (state, action) => { ...state, screenManager: SCREEN_MANAGER.SHOW_HOME_SCREEN, }; + case SCREEN_MANAGER.SHOW_LEADERBOARD_SCREEN: + return { + ...state, + screenManager: SCREEN_MANAGER.SHOW_LEADERBOARD_SCREEN, + }; + case SCREEN_MANAGER.SHOW_BADGES_SCREEN: + return { + ...state, + screenManager: SCREEN_MANAGER.SHOW_BADGES_SCREEN, + }; + case SCREEN_MANAGER.SHOW_SWITCH_TRACK_SCREEN: + return { + ...state, + screenManager: SCREEN_MANAGER.SHOW_SWITCH_TRACK_SCREEN, + }; case SCREEN_MANAGER.SHOW_ON_YOUR_MARK_SCREEN: return { ...state, @@ -80,6 +95,8 @@ const globalReducer = (state, action) => { numberOfCheckpoints: payload.numberOfCheckpoints, startTimestamp: payload.startTimestamp, tracks: payload.tracks, + visitorInventory: payload.visitorInventory, + badges: payload.badges, error: "", }; case SET_ERROR: diff --git a/client/src/context/types.js b/client/src/context/types.js index d41b7bf..52881a6 100644 --- a/client/src/context/types.js +++ b/client/src/context/types.js @@ -7,6 +7,9 @@ export const CANCEL_RACE = "CANCEL_RACE"; export const RESET_GAME = "RESET_GAME"; export const SCREEN_MANAGER = { SHOW_HOME_SCREEN: "SHOW_HOME_SCREEN", + SHOW_LEADERBOARD_SCREEN: "SHOW_LEADERBOARD_SCREEN", + SHOW_BADGES_SCREEN: "SHOW_BADGES_SCREEN", + SHOW_SWITCH_TRACK_SCREEN: "SHOW_SWITCH_TRACK_SCREEN", SHOW_ON_YOUR_MARK_SCREEN: "SHOW_ON_YOUR_MARK_SCREEN", SHOW_RACE_IN_PROGRESS_SCREEN: "SHOW_RACE_IN_PROGRESS_SCREEN", SHOW_RACE_COMPLETED_SCREEN: "SHOW_RACE_COMPLETED_SCREEN", diff --git a/client/src/index.scss b/client/src/index.scss index 0373698..24451b9 100644 --- a/client/src/index.scss +++ b/client/src/index.scss @@ -1,39 +1,100 @@ +.page-container { + height: 100vh; + width: 100%; + position: fixed; + top: 0; + bottom: 0; + background-color: #1a8dff; + z-index: -1; +} + .footer-fixed { position: fixed; bottom: 0; + left: 0; width: 100%; max-width: 352px; - padding: 10px 0; text-align: center; - background-color: white; } -.icon-with-rounded-border { +.text-white { + color: #ffffff !important; +} + +.text-yellow { + color: #fdb41d !important; +} + +.btn-primary, +.btn-primary-outline { + background-color: #fdb41d; + border: 4px solid #fdb41d; + padding: 12px 20px; border-radius: 50px; - border: 1px solid #ebedef; - display: flex; - width: 40px; - height: 40px; - justify-content: center; - align-items: center; + font-size: 18px; + font-weight: bold; + width: 100%; +} + +.btn-primary-outline { + background-color: #ffffff; +} + +.icon-btn { cursor: pointer; + color: white; + background: transparent; + font-size: 32px; + line-height: 32px; } -.btn-danger-outline:disabled:hover { +.card-primary, +.card-outline { background: #ffffff; - border-color: #d6dbdf; - color: #d6dbdf; - cursor: not-allowed; + border: 4px solid #01aefe; + border-radius: 25px; + padding: 16px; } -.small-to-large { - animation: grow 1s forwards; +.card-primary { + background: #caedff; +} + +.tab { + background: #caedff; + border: 1px solid #ffffff; + border-radius: 25px; + padding: 6px 4px; + font-size: 14px; +} + +.track { + border-radius: 25px; + border: 2px solid #000000; + background: #cccccc; + height: 120px; +} + +table { + tr:nth-child(even) { + background-color: #f8f8f8; + } } -.heartbeat { - font-size: 40px; - color: #e74c3c; - animation: heartbeat 1.5s infinite; +.checkpoint { + border-radius: 50%; + border: 2px solid #000000; + background-color: #ffffff; + width: 24px; + height: 24px; + display: inline-block; +} +.completed { + background-color: #fdb41d; +} + +.small-to-large { + animation: grow 1s forwards; } @keyframes grow { diff --git a/client/src/pages/Home.jsx b/client/src/pages/Home.jsx index 6fc8353..7433535 100644 --- a/client/src/pages/Home.jsx +++ b/client/src/pages/Home.jsx @@ -1,13 +1,16 @@ import { useContext, useState, useEffect } from "react"; // components -import OnYourMarkScreen from "@components/OnYourMarkScreen/OnYourMarkScreen"; -import RaceInProgressScreen from "@components/RaceInProgressScreen/RaceInProgressScreen"; -import NewGameScreen from "@components/NewGameScreen/NewGameScreen"; -import RaceCompletedScreen from "@components/RaceCompletedScreen/RaceCompletedScreen"; -import AdminGear from "@components/Admin/AdminGear"; -import AdminView from "@components/Admin/AdminView"; -import Loading from "@components/Shared/Loading"; +import { + PageContainer, + NewGameScreen, + LeaderboardScreen, + BadgesScreen, + SwitchTrackScreen, + OnYourMarkScreen, + RaceInProgressScreen, + RaceCompletedScreen, +} from "@components"; // context import { SCREEN_MANAGER } from "@context/types"; @@ -18,9 +21,8 @@ import { backendAPI, loadGameState } from "@utils"; function Home() { const dispatch = useContext(GlobalDispatchContext); - const { error, screenManager, isAdmin } = useContext(GlobalStateContext); + const { screenManager } = useContext(GlobalStateContext); const [loading, setLoading] = useState(true); - const [showSettings, setShowSettings] = useState(false); useEffect(() => { const fetchGameState = async () => { @@ -37,21 +39,29 @@ function Home() { fetchGameState(); }, [dispatch, backendAPI]); - if (loading) return ; - - if (showSettings) { - return ; - } - return ( -
- {isAdmin && } - {screenManager === SCREEN_MANAGER.SHOW_ON_YOUR_MARK_SCREEN && } - {screenManager === SCREEN_MANAGER.SHOW_RACE_IN_PROGRESS_SCREEN && } - {screenManager === SCREEN_MANAGER.SHOW_HOME_SCREEN && } - {screenManager === SCREEN_MANAGER.SHOW_RACE_COMPLETED_SCREEN && } - {error &&

{error}

} -
+ + {(() => { + switch (screenManager) { + case SCREEN_MANAGER.SHOW_HOME_SCREEN: + return ; + case SCREEN_MANAGER.SHOW_LEADERBOARD_SCREEN: + return ; + case SCREEN_MANAGER.SHOW_BADGES_SCREEN: + return ; + case SCREEN_MANAGER.SHOW_SWITCH_TRACK_SCREEN: + return ; + case SCREEN_MANAGER.SHOW_ON_YOUR_MARK_SCREEN: + return ; + case SCREEN_MANAGER.SHOW_RACE_IN_PROGRESS_SCREEN: + return ; + case SCREEN_MANAGER.SHOW_RACE_COMPLETED_SCREEN: + return ; + default: + return null; + } + })()} + ); } diff --git a/client/src/pages/Leaderboard.jsx b/client/src/pages/Leaderboard.jsx deleted file mode 100644 index 6dc8adb..0000000 --- a/client/src/pages/Leaderboard.jsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useContext, useState, useEffect } from "react"; -import { Link, useLocation } from "react-router-dom"; -import "./Leaderboard.scss"; - -// components -import AdminGear from "@components/Admin/AdminGear"; -import AdminView from "@components/Admin/AdminView"; -import Loading from "@components/Shared/Loading"; -import Footer from "@components/Shared/Footer"; - -// context -import { GlobalStateContext, GlobalDispatchContext } from "@context/GlobalContext"; - -// utils -import { backendAPI, loadGameState } from "@utils"; - -function Leaderboard() { - const dispatch = useContext(GlobalDispatchContext); - const { leaderboard, highScore, isAdmin } = useContext(GlobalStateContext); - const [loading, setLoading] = useState(true); - const [showSettings, setShowSettings] = useState(false); - const location = useLocation(); - - useEffect(() => { - const fetchGameState = async () => { - try { - setLoading(true); - await loadGameState(dispatch); - } catch (error) { - console.error("error in loadGameState action"); - } finally { - setLoading(false); - } - }; - - fetchGameState(); - }, [dispatch, backendAPI]); - - if (loading) return ; - - if (showSettings) return ; - - const queryParams = new URLSearchParams(location.search); - - return ( - <> - {isAdmin && } -
-
-
🏅
-

Personal Best

-

{highScore || "No highScore available"}

-
-
🏆
-
-

Leaderboard

-
- - - - - - - - - - {leaderboard?.length === 0 ? ( - - - - ) : ( - leaderboard?.map((item, index) => { - return ( - - - - - - ); - }) - )} - -
NameTime
There are no race finishes yet.
{index + 1}{item.displayName}{item.highScore}
-
-
- - - -
- - ); -} - -export default Leaderboard; diff --git a/client/src/pages/Leaderboard.scss b/client/src/pages/Leaderboard.scss deleted file mode 100644 index f0f4ec7..0000000 --- a/client/src/pages/Leaderboard.scss +++ /dev/null @@ -1,19 +0,0 @@ -.leaderboard-table { - tr:nth-child(even) { - background-color: #f8f8f8; - } -} - -.icon { - font-size: 28px; - text-align: center; -} - -.highScore-container { - text-align: center; - margin-bottom: 20px; - padding: 10px; - border: 2px solid #ddd; - border-radius: 8px; - background-color: #f9f9f9; -} diff --git a/client/src/pages/LeaderboardPage.jsx b/client/src/pages/LeaderboardPage.jsx new file mode 100644 index 0000000..a7e2247 --- /dev/null +++ b/client/src/pages/LeaderboardPage.jsx @@ -0,0 +1,38 @@ +import { useContext, useState, useEffect } from "react"; + +// components +import { PageContainer, Leaderboard } from "@components"; + +// context +import { GlobalDispatchContext } from "@context/GlobalContext"; + +// utils +import { backendAPI, loadGameState } from "@utils"; + +export const LeaderboardPage = () => { + const dispatch = useContext(GlobalDispatchContext); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchGameState = async () => { + try { + setLoading(true); + await loadGameState(dispatch); + } catch (error) { + console.error("error in loadGameState action"); + } finally { + setLoading(false); + } + }; + + fetchGameState(); + }, [dispatch, backendAPI]); + + return ( + + + + ); +}; + +export default LeaderboardPage; diff --git a/client/src/utils/loadGameState.js b/client/src/utils/loadGameState.js index 09fa205..157115e 100644 --- a/client/src/utils/loadGameState.js +++ b/client/src/utils/loadGameState.js @@ -5,33 +5,48 @@ export const loadGameState = async (dispatch) => { try { const result = await backendAPI?.post("/race/game-state"); if (result?.data?.success) { + const { + checkpointsCompleted, + elapsedTimeInSeconds, + highScore, + isAdmin, + leaderboard, + numberOfCheckpoints, + startTimestamp, + endTimestamp, + tracks, + visitorInventory, + badges, + } = result.data; await dispatch({ type: LOAD_GAME_STATE, payload: { - checkpointsCompleted: result.data.checkpointsCompleted, - elapsedTimeInSeconds: result.data.elapsedTimeInSeconds, - highScore: result.data.highScore, - isAdmin: result.data.isAdmin, - leaderboard: result.data.leaderboard, - numberOfCheckpoints: result.data.numberOfCheckpoints, - startTimestamp: result.data.startTimestamp, - tracks: result.data.tracks, + checkpointsCompleted, + elapsedTimeInSeconds, + highScore, + isAdmin, + leaderboard, + numberOfCheckpoints, + startTimestamp, + tracks, + visitorInventory, + badges, }, }); - if (result.data.startTimestamp && !result.data.endTimestamp) { + if (startTimestamp && !endTimestamp) { await dispatch({ type: SCREEN_MANAGER.SHOW_RACE_IN_PROGRESS_SCREEN, }); } - if (result.data.startTimestamp && result.data.endTimestamp) { + if (startTimestamp && endTimestamp) { await dispatch({ type: SCREEN_MANAGER.SHOW_RACE_COMPLETED, }); } - if (!result.data.startTimestamp) { + if (!startTimestamp) { await dispatch({ type: SCREEN_MANAGER.SHOW_HOME_SCREEN, }); diff --git a/server/controllers/handleCompleteRace.js b/server/controllers/handleCompleteRace.js index 644f750..2321dab 100644 --- a/server/controllers/handleCompleteRace.js +++ b/server/controllers/handleCompleteRace.js @@ -4,10 +4,10 @@ export const handleCompleteRace = async (req, res) => { try { const credentials = getCredentials(req.query); - const { visitorProgress } = await getVisitor(credentials); + const { visitorProgress, visitorInventory } = await getVisitor(credentials); const elapsedTime = visitorProgress.elapsedTime; - return res.json({ success: true, elapsedTime }); + return res.json({ success: true, elapsedTime, visitorInventory }); } catch (error) { return errorHandler({ error, diff --git a/server/controllers/handleLoadGameState.js b/server/controllers/handleLoadGameState.js index 1c8592b..61133b3 100644 --- a/server/controllers/handleLoadGameState.js +++ b/server/controllers/handleLoadGameState.js @@ -1,4 +1,4 @@ -import { World, errorHandler, getCredentials, getVisitor } from "../utils/index.js"; +import { Ecosystem, World, errorHandler, getCredentials, getInventoryItems, getVisitor } from "../utils/index.js"; import { TRACKS } from "../constants.js"; export const handleLoadGameState = async (req, res) => { @@ -57,7 +57,7 @@ export const handleLoadGameState = async (req, res) => { ); } - const { visitor, visitorProgress } = await getVisitor(credentials, true); + const { visitor, visitorProgress, visitorInventory } = await getVisitor(credentials, true); const { checkpoints, highScore, startTimestamp } = visitorProgress; const leaderboard = []; @@ -80,6 +80,8 @@ export const handleLoadGameState = async (req, res) => { }; leaderboard.sort((a, b) => timeToSeconds(a.highScore) - timeToSeconds(b.highScore)).slice(0, 20); + const { badges } = await getInventoryItems(credentials); + return res.json({ checkpointsCompleted: checkpoints, elapsedTimeInSeconds: startTimestamp ? Math.floor((now - startTimestamp) / 1000) : 0, @@ -90,6 +92,8 @@ export const handleLoadGameState = async (req, res) => { startTimestamp, success: true, tracks: parseEnvJson(process.env.TRACKS) || TRACKS, + visitorInventory, + badges, }); } catch (error) { return errorHandler({ diff --git a/server/controllers/handleResetGame.js b/server/controllers/handleResetGame.js index 69c6d5d..f470338 100644 --- a/server/controllers/handleResetGame.js +++ b/server/controllers/handleResetGame.js @@ -1,5 +1,5 @@ import { DEFAULT_PROGRESS } from "../constants.js"; -import { World, errorHandler, getCredentials, updateVisitorProgress } from "../utils/index.js"; +import { World, errorHandler, getCredentials, getVisitor, updateVisitorProgress } from "../utils/index.js"; export const handleResetGame = async (req, res) => { try { diff --git a/server/utils/badges/getInventoryItems.js b/server/utils/badges/getInventoryItems.js new file mode 100644 index 0000000..67acc81 --- /dev/null +++ b/server/utils/badges/getInventoryItems.js @@ -0,0 +1,34 @@ +import { Ecosystem } from "../index.js"; + +export const getInventoryItems = async (credentials) => { + try { + const ecosystem = await Ecosystem.create({ credentials }); + await ecosystem.fetchInventoryItems(); + + const badges = {}; + + for (const item of ecosystem.inventoryItems) { + badges[item.id] = { + id: item.id, + name: item.name || "Unknown", + icon: item.image_path || "", + description: item.description || "", + }; + } + + // Sort items by sortOrder while keeping them as objects + const sortedBadges = {}; + + Object.values(badges) + .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) + .forEach((badge) => { + sortedBadges[badge.id] = badge; + }); + + return { + badges: sortedBadges, + }; + } catch (error) { + return standardizeError(error); + } +}; diff --git a/server/utils/badges/index.js b/server/utils/badges/index.js index 78da136..cbe1195 100644 --- a/server/utils/badges/index.js +++ b/server/utils/badges/index.js @@ -1,3 +1,4 @@ -export * from "./getTimeInSeconds.js"; export * from "./awardBadge.js"; +export * from "./getInventoryItems.js"; +export * from "./getTimeInSeconds.js"; export * from "./isNewHighScoreTop3.js"; From 247ca66d03f1ad4858b5049665c1f3310fe1378b Mon Sep 17 00:00:00 2001 From: Lina Date: Wed, 17 Dec 2025 17:55:51 -0800 Subject: [PATCH 11/20] add New Badge Modal --- .../components/BadgesScreen/BadgesScreen.jsx | 35 ++++++++++- .../RaceCompletedScreen/NewBadgeModal.jsx | 43 +++++++++++++ .../RaceCompletedScreen.jsx | 34 ++++++++--- .../RaceInProgressScreen.jsx | 2 +- .../SwitchRace/SwitchTrackScreen.jsx | 61 +++++++++++++------ client/src/components/index.js | 1 + client/src/context/reducer.js | 18 ++++++ client/src/context/types.js | 2 + client/src/index.scss | 21 +++++++ client/src/utils/completeRace.js | 5 +- client/src/utils/loadGameState.js | 3 + server/controllers/handleCheckpointEntered.js | 2 +- .../controllers/handleGetVisitorInventory.js | 19 ++++++ server/controllers/handleLoadGameState.js | 3 +- server/controllers/handleSwitchTrack.js | 13 ++-- server/controllers/index.js | 9 +-- server/routes.js | 11 ++-- server/utils/badges/getInventoryItems.js | 4 +- server/utils/checkpoints/finishLineEntered.js | 17 +++++- 19 files changed, 255 insertions(+), 48 deletions(-) create mode 100644 client/src/components/RaceCompletedScreen/NewBadgeModal.jsx create mode 100644 server/controllers/handleGetVisitorInventory.js diff --git a/client/src/components/BadgesScreen/BadgesScreen.jsx b/client/src/components/BadgesScreen/BadgesScreen.jsx index e6f371f..3e5a5b1 100644 --- a/client/src/components/BadgesScreen/BadgesScreen.jsx +++ b/client/src/components/BadgesScreen/BadgesScreen.jsx @@ -1,16 +1,45 @@ -import { useContext } from "react"; +import { useContext, useEffect, useState } from "react"; // components -import { BackButton } from "@components"; +import { BackButton, Loading } from "@components"; // context import { GlobalDispatchContext, GlobalStateContext } from "@context/GlobalContext"; -import { SCREEN_MANAGER } from "@context/types"; +import { SCREEN_MANAGER, SET_VISITOR_INVENTORY, SET_ERROR } from "@context/types"; + +// utils +import { backendAPI, getErrorMessage } from "@utils"; export const BadgesScreen = () => { const dispatch = useContext(GlobalDispatchContext); const { badges, visitorInventory } = useContext(GlobalStateContext); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + setIsLoading(true); + + const getVisitorInventory = async () => { + await backendAPI + .get("/visitor-inventory") + .then((response) => { + dispatch({ type: SET_VISITOR_INVENTORY, payload: response.data }); + }) + .catch((error) => { + dispatch({ + type: SET_ERROR, + payload: { error: getErrorMessage("getting visitor inventory", error) }, + }); + }) + .finally(() => { + setIsLoading(false); + }); + }; + getVisitorInventory(); + }, []); + + if (isLoading) return ; + return ( <> dispatch({ type: SCREEN_MANAGER.SHOW_HOME_SCREEN })} /> diff --git a/client/src/components/RaceCompletedScreen/NewBadgeModal.jsx b/client/src/components/RaceCompletedScreen/NewBadgeModal.jsx new file mode 100644 index 0000000..dd5987e --- /dev/null +++ b/client/src/components/RaceCompletedScreen/NewBadgeModal.jsx @@ -0,0 +1,43 @@ +import PropTypes from "prop-types"; + +// context +import { GlobalDispatchContext } from "@context/GlobalContext"; +import { SCREEN_MANAGER } from "@context/types"; +import { useContext } from "react"; + +export const NewBadgeModal = ({ badge, handleToggleShowModal }) => { + const dispatch = useContext(GlobalDispatchContext); + const { name, icon } = badge; + + return ( +
+
+
+

New Badge Unlocked!

+ +
+
+ {name} +

+ {name} +

+ +
+
+
+ ); +}; + +NewBadgeModal.propTypes = { + badge: PropTypes.shape({ + name: PropTypes.string, + icon: PropTypes.string, + }), + handleToggleShowModal: PropTypes.func, +}; + +export default NewBadgeModal; diff --git a/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx b/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx index 0b7239a..cd36836 100644 --- a/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx +++ b/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx @@ -1,7 +1,8 @@ -import { useContext } from "react"; +import { useContext, useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; // components -import { Footer, Tabs } from "@components"; +import { Footer, NewBadgeModal, Tabs } from "@components"; // context import { GlobalStateContext, GlobalDispatchContext } from "@context/GlobalContext"; @@ -9,11 +10,28 @@ import { SCREEN_MANAGER } from "@context/types"; export const RaceCompletedScreen = () => { const dispatch = useContext(GlobalDispatchContext); - const { elapsedTime } = useContext(GlobalStateContext); + const { elapsedTime, badges } = useContext(GlobalStateContext); - function handlePlayAgain() { - dispatch({ type: SCREEN_MANAGER.SHOW_HOME_SCREEN }); - } + const [newBadgeKey, setNewBadgeKey] = useState(); + + const [searchParams] = useSearchParams(); + const profileId = searchParams.get("profileId"); + + useEffect(() => { + if (profileId) { + const eventSource = new EventSource(`/api/events?profileId=${profileId}`); + eventSource.onmessage = function (event) { + const newEvent = JSON.parse(event.data); + if (newEvent.badgeKey) setNewBadgeKey(newEvent.badgeKey); + }; + eventSource.onerror = (event) => { + console.error("Server Event error:", event); + }; + return () => { + eventSource.close(); + }; + } + }, [profileId]); return ( <> @@ -35,10 +53,12 @@ export const RaceCompletedScreen = () => {
-
+ + {newBadgeKey && {}} />} ); }; diff --git a/client/src/components/RaceInProgressScreen/RaceInProgressScreen.jsx b/client/src/components/RaceInProgressScreen/RaceInProgressScreen.jsx index bad165f..1bed21d 100644 --- a/client/src/components/RaceInProgressScreen/RaceInProgressScreen.jsx +++ b/client/src/components/RaceInProgressScreen/RaceInProgressScreen.jsx @@ -123,7 +123,7 @@ export const RaceInProgressScreen = () => { if (allCompleted && !completeRaceCalledRef.current) { completeRaceCalledRef.current = true; successAudioRef.current.play(); - completeRace({ dispatch, currentFinishedElapsedTime }); + completeRace({ dispatch }); } }, [checkpoints, isFinishComplete, currentFinishedElapsedTime, dispatch]); diff --git a/client/src/components/SwitchRace/SwitchTrackScreen.jsx b/client/src/components/SwitchRace/SwitchTrackScreen.jsx index 191e6f3..ab88f9a 100644 --- a/client/src/components/SwitchRace/SwitchTrackScreen.jsx +++ b/client/src/components/SwitchRace/SwitchTrackScreen.jsx @@ -1,34 +1,57 @@ -import { useState, useContext } from "react"; +import { useState, useContext, useEffect } from "react"; // components import { BackButton, Footer } from "@components"; // context import { GlobalDispatchContext, GlobalStateContext } from "@context/GlobalContext"; -import { SET_ERROR, SCREEN_MANAGER } from "@context/types"; +import { SET_ERROR, SCREEN_MANAGER, SET_SCENE_DATA } from "@context/types"; // utils import { backendAPI, getErrorMessage } from "@utils"; export const SwitchTrackScreen = () => { const dispatch = useContext(GlobalDispatchContext); - const { tracks } = useContext(GlobalStateContext); + const { tracks, trackLastSwitchedDate } = useContext(GlobalStateContext); const [selectedTrack, setSelectedTrack] = useState(null); - const [areAllButtonsDisabled, setAreAllButtonsDisabled] = useState(false); + const [areAllButtonsDisabled, setAreAllButtonsDisabled] = useState(true); + + useEffect(() => { + if (trackLastSwitchedDate) { + const lastSwitch = trackLastSwitchedDate; + const now = new Date().getTime(); + const diffMs = now - lastSwitch; + const diffMinutes = diffMs / (100 * 60); + setAreAllButtonsDisabled(diffMinutes < 30); + } else { + setAreAllButtonsDisabled(false); + } + }, [trackLastSwitchedDate]); const updateTrack = async () => { setAreAllButtonsDisabled(true); await backendAPI .post(`/race/switch-track?trackSceneId=${selectedTrack.sceneId}`) + .then((response) => { + const { leaderboard, numberOfCheckpoints, trackLastSwitchedDate } = response.data.sceneData; + + dispatch({ + type: SET_SCENE_DATA, + payload: { + leaderboard, + numberOfCheckpoints, + tracks, + trackLastSwitchedDate, + }, + }); + }) .catch((error) => { dispatch({ type: SET_ERROR, payload: { error: getErrorMessage("resetting", error) }, }); - }) - .finally(() => { setAreAllButtonsDisabled(false); }); }; @@ -43,18 +66,20 @@ export const SwitchTrackScreen = () => {
- {tracks?.map((track) => ( - - ))} + {tracks?.map((track) => { + return ( + + ); + })}
diff --git a/client/src/components/index.js b/client/src/components/index.js index 6c09052..014642f 100644 --- a/client/src/components/index.js +++ b/client/src/components/index.js @@ -5,6 +5,7 @@ export * from "./Leaderboard/LeaderboardScreen.jsx"; export * from "./BadgesScreen/BadgesScreen.jsx"; export * from "./NewGameScreen/NewGameScreen.jsx"; export * from "./OnYourMarkScreen/OnYourMarkScreen.jsx"; +export * from "./RaceCompletedScreen/NewBadgeModal.jsx"; export * from "./RaceCompletedScreen/RaceCompletedScreen.jsx"; export * from "./RaceInProgressScreen/Checkpoint.jsx"; export * from "./RaceInProgressScreen/RaceInProgressScreen.jsx"; diff --git a/client/src/context/reducer.js b/client/src/context/reducer.js index 0cb506c..6836ebb 100644 --- a/client/src/context/reducer.js +++ b/client/src/context/reducer.js @@ -6,6 +6,8 @@ import { CANCEL_RACE, LOAD_GAME_STATE, RESET_GAME, + SET_VISITOR_INVENTORY, + SET_SCENE_DATA, SET_ERROR, } from "./types"; @@ -68,6 +70,7 @@ const globalReducer = (state, action) => { ...state, screenManager: SCREEN_MANAGER.SHOW_RACE_COMPLETED_SCREEN, elapsedTime: payload.elapsedTime, + visitorInventory: payload.visitorInventory, error: "", }; case CANCEL_RACE: @@ -97,6 +100,21 @@ const globalReducer = (state, action) => { tracks: payload.tracks, visitorInventory: payload.visitorInventory, badges: payload.badges, + trackLastSwitchedDate: payload.trackLastSwitchedDate, + error: "", + }; + case SET_VISITOR_INVENTORY: + return { + ...state, + visitorInventory: payload.visitorInventory, + error: "", + }; + case SET_SCENE_DATA: + return { + ...state, + leaderboard: payload.leaderboard, + numberOfCheckpoints: payload.numberOfCheckpoints, + trackLastSwitchedDate: payload.trackLastSwitchedDate, error: "", }; case SET_ERROR: diff --git a/client/src/context/types.js b/client/src/context/types.js index 52881a6..c3691a5 100644 --- a/client/src/context/types.js +++ b/client/src/context/types.js @@ -5,6 +5,8 @@ export const START_RACE = "START_RACE"; export const COMPLETE_RACE = "COMPLETE_RACE"; export const CANCEL_RACE = "CANCEL_RACE"; export const RESET_GAME = "RESET_GAME"; +export const SET_VISITOR_INVENTORY = "SET_VISITOR_INVENTORY"; +export const SET_SCENE_DATA = "SET_SCENE_DATA"; export const SCREEN_MANAGER = { SHOW_HOME_SCREEN: "SHOW_HOME_SCREEN", SHOW_LEADERBOARD_SCREEN: "SHOW_LEADERBOARD_SCREEN", diff --git a/client/src/index.scss b/client/src/index.scss index 24451b9..1b59b5d 100644 --- a/client/src/index.scss +++ b/client/src/index.scss @@ -40,6 +40,23 @@ background-color: #ffffff; } +.btn-primary:disabled { + background-color: #e2dfd7; + border: 4px solid #ffd880; + color: #ffffff; +} + +.btn-secondary { + background-color: #1a8dff; + border: 4px solid #1a8dff; + color: #ffffff; + padding: 12px 20px; + border-radius: 50px; + font-size: 18px; + font-weight: bold; + width: 100%; +} + .icon-btn { cursor: pointer; color: white; @@ -75,6 +92,10 @@ height: 120px; } +.selected { + border: 2px solid #fdb41d; +} + table { tr:nth-child(even) { background-color: #f8f8f8; diff --git a/client/src/utils/completeRace.js b/client/src/utils/completeRace.js index 0b1961b..dc25e76 100644 --- a/client/src/utils/completeRace.js +++ b/client/src/utils/completeRace.js @@ -1,14 +1,15 @@ import { backendAPI, getErrorMessage } from "@utils"; import { SET_ERROR, COMPLETE_RACE } from "@context/types"; -export const completeRace = async ({ dispatch, currentFinishedElapsedTime }) => { +export const completeRace = async ({ dispatch }) => { try { const result = await backendAPI.post("/race/complete-race"); if (result.status === 200) { dispatch({ type: COMPLETE_RACE, payload: { - elapsedTime: currentFinishedElapsedTime, + elapsedTime: result.data.elapsedTime, + visitorInventory: result.data.visitorInventory, }, }); } diff --git a/client/src/utils/loadGameState.js b/client/src/utils/loadGameState.js index 157115e..f63df0c 100644 --- a/client/src/utils/loadGameState.js +++ b/client/src/utils/loadGameState.js @@ -17,7 +17,9 @@ export const loadGameState = async (dispatch) => { tracks, visitorInventory, badges, + trackLastSwitchedDate, } = result.data; + await dispatch({ type: LOAD_GAME_STATE, payload: { @@ -31,6 +33,7 @@ export const loadGameState = async (dispatch) => { tracks, visitorInventory, badges, + trackLastSwitchedDate, }, }); diff --git a/server/controllers/handleCheckpointEntered.js b/server/controllers/handleCheckpointEntered.js index 7e26982..8235cc8 100644 --- a/server/controllers/handleCheckpointEntered.js +++ b/server/controllers/handleCheckpointEntered.js @@ -69,7 +69,7 @@ export const handleCheckpointEntered = async (req, res) => { checkpointNumber, currentRaceFinishedElapsedTime: currentElapsedTime, }); - const result = await finishLineEntered({ credentials, currentElapsedTime, wasWrongCheckpointEntered }); + const result = await finishLineEntered({ credentials, currentElapsedTime, wasWrongCheckpointEntered, redisObj }); if (result instanceof Error) throw result; } else { const result = await checkpointEntered({ diff --git a/server/controllers/handleGetVisitorInventory.js b/server/controllers/handleGetVisitorInventory.js new file mode 100644 index 0000000..c873266 --- /dev/null +++ b/server/controllers/handleGetVisitorInventory.js @@ -0,0 +1,19 @@ +import { errorHandler, getCredentials, getVisitor } from "../utils/index.js"; + +export const handleGetVisitorInventory = async (req, res) => { + try { + const credentials = getCredentials(req.query); + + const { visitorInventory } = await getVisitor(credentials); + + return res.json({ success: true, visitorInventory }); + } catch (error) { + return errorHandler({ + error, + functionName: "handleGetVisitorInventory", + message: "Error getting visitor inventory", + req, + res, + }); + } +}; diff --git a/server/controllers/handleLoadGameState.js b/server/controllers/handleLoadGameState.js index 61133b3..2adeb22 100644 --- a/server/controllers/handleLoadGameState.js +++ b/server/controllers/handleLoadGameState.js @@ -1,4 +1,4 @@ -import { Ecosystem, World, errorHandler, getCredentials, getInventoryItems, getVisitor } from "../utils/index.js"; +import { World, errorHandler, getCredentials, getInventoryItems, getVisitor } from "../utils/index.js"; import { TRACKS } from "../constants.js"; export const handleLoadGameState = async (req, res) => { @@ -94,6 +94,7 @@ export const handleLoadGameState = async (req, res) => { tracks: parseEnvJson(process.env.TRACKS) || TRACKS, visitorInventory, badges, + trackLastSwitchedDate: sceneData.trackLastSwitchedDate || null, }); } catch (error) { return errorHandler({ diff --git a/server/controllers/handleSwitchTrack.js b/server/controllers/handleSwitchTrack.js index 6ad33a9..9640850 100644 --- a/server/controllers/handleSwitchTrack.js +++ b/server/controllers/handleSwitchTrack.js @@ -64,11 +64,16 @@ export const handleSwitchTrack = async (req, res) => { isPartial: true, }); + const sceneData = { + numberOfCheckpoints: numberOfCheckpoints?.length, + leaderboard: {}, + position, + trackLastSwitchedDate: new Date().getTime(), + }; + await world.updateDataObject( { - [`${sceneDropId}.numberOfCheckpoints`]: numberOfCheckpoints?.length, - [`${sceneDropId}.leaderboard`]: {}, - [`${sceneDropId}.position`]: position, + [sceneDropId]: sceneData, }, { analytics: [{ analyticName: "trackUpdates", profileId, uniqueKey: profileId }] }, ); @@ -83,7 +88,7 @@ export const handleSwitchTrack = async (req, res) => { const droppedAsset = DroppedAsset.create(assetId, urlSlug, { credentials }); await droppedAsset.deleteDroppedAsset(); - return res.json({ success: true }); + return res.json({ success: true, sceneData }); } catch (error) { return errorHandler({ error, diff --git a/server/controllers/index.js b/server/controllers/index.js index 926a742..be402b7 100644 --- a/server/controllers/index.js +++ b/server/controllers/index.js @@ -1,8 +1,9 @@ -export * from "./handleRaceStart.js"; -export * from "./handleCheckpointEntered.js"; -export * from "./handleLoadGameState.js"; export * from "./handleCancelRace.js"; -export * from "./handleGetEvents.js"; +export * from "./handleCheckpointEntered.js"; export * from "./handleCompleteRace.js"; +export * from "./handleRaceStart.js"; +export * from "./handleGetEvents.js"; +export * from "./handleGetVisitorInventory.js"; +export * from "./handleLoadGameState.js"; export * from "./handleResetGame.js"; export * from "./handleSwitchTrack.js"; diff --git a/server/routes.js b/server/routes.js index f8caef1..49ab2c7 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1,12 +1,13 @@ import express from "express"; import { - handleRaceStart, - handleCheckpointEntered, - handleLoadGameState, handleCancelRace, - handleGetEvents, + handleCheckpointEntered, handleCompleteRace, + handleGetEvents, + handleGetVisitorInventory, + handleLoadGameState, + handleRaceStart, handleResetGame, handleSwitchTrack, } from "./controllers/index.js"; @@ -37,6 +38,8 @@ router.get("/system/health", (req, res) => { }); }); +router.get("/visitor-inventory", handleGetVisitorInventory); + // Race router.post("/race/game-state", handleLoadGameState); router.post("/race/start-race", handleRaceStart); diff --git a/server/utils/badges/getInventoryItems.js b/server/utils/badges/getInventoryItems.js index 67acc81..55777a7 100644 --- a/server/utils/badges/getInventoryItems.js +++ b/server/utils/badges/getInventoryItems.js @@ -8,7 +8,7 @@ export const getInventoryItems = async (credentials) => { const badges = {}; for (const item of ecosystem.inventoryItems) { - badges[item.id] = { + badges[item.name] = { id: item.id, name: item.name || "Unknown", icon: item.image_path || "", @@ -22,7 +22,7 @@ export const getInventoryItems = async (credentials) => { Object.values(badges) .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) .forEach((badge) => { - sortedBadges[badge.id] = badge; + sortedBadges[badge.name] = badge; }); return { diff --git a/server/utils/checkpoints/finishLineEntered.js b/server/utils/checkpoints/finishLineEntered.js index ebe769e..e10f4eb 100644 --- a/server/utils/checkpoints/finishLineEntered.js +++ b/server/utils/checkpoints/finishLineEntered.js @@ -9,11 +9,12 @@ import { updateVisitorProgress, } from "../index.js"; -export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWrongCheckpointEntered }) => { +export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWrongCheckpointEntered, redisObj }) => { try { const { assetId, displayName, profileId, sceneDropId, urlSlug } = credentials; const promises = []; + let newBadgeKey; const world = World.create(urlSlug, { credentials }); await world.fetchDataObject(); @@ -92,6 +93,7 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr // Award Race Rookie badge if this is the visitor's first high score if (!visitorProgress.highScore) { + newBadgeKey = "Race Rookie"; promises.push( awardBadge({ credentials, visitor, visitorInventory, badgeName: "Race Rookie" }).catch((error) => errorHandler({ @@ -106,6 +108,7 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr // Award Top 3 Racer badge if newHighScore is in top 3 of leaderboard const shouldGetTop3Badge = await isNewHighScoreTop3(raceObject.leaderboard, newHighScore); if (shouldGetTop3Badge) { + newBadgeKey = "Top 3 Racer"; promises.push( awardBadge({ credentials, visitor, visitorInventory, badgeName: "Top 3 Racer" }).catch((error) => errorHandler({ @@ -121,6 +124,7 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr const [min, sec, mili] = currentElapsedTime.split(":").map(Number); const totalSeconds = min * 60 + sec + mili / 100; if (totalSeconds < 30) { + newBadgeKey = "Speed Demon"; promises.push( awardBadge({ credentials, visitor, visitorInventory, badgeName: "Speed Demon" }).catch((error) => errorHandler({ @@ -131,6 +135,7 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr ), ); } else if (totalSeconds > 120) { + newBadgeKey = "Slow & Steady"; promises.push( awardBadge({ credentials, visitor, visitorInventory, badgeName: "Slow & Steady" }).catch((error) => errorHandler({ @@ -144,6 +149,7 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr // Award Race Pro badge if visitor has completed 100 races or Race Expert badge if visitor has completed 1000 races if (visitor.dataObject.racesCompleted + 1 === 100) { + newBadgeKey = "Race Pro"; promises.push( awardBadge({ credentials, visitor, visitorInventory, badgeName: "Race Pro" }).catch((error) => errorHandler({ @@ -154,6 +160,7 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr ), ); } else if (visitor.dataObject.racesCompleted + 1 === 1000) { + newBadgeKey = "Race Expert"; promises.push( awardBadge({ credentials, visitor, visitorInventory, badgeName: "Race Expert" }).catch((error) => errorHandler({ @@ -167,6 +174,7 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr // Award Never Give Up badge if visitor completed the race after previously entering a wrong checkpoint if (wasWrongCheckpointEntered) { + newBadgeKey = "Never Give Up"; promises.push( awardBadge({ credentials, visitor, visitorInventory, badgeName: "Never Give Up" }).catch((error) => errorHandler({ @@ -183,6 +191,13 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr if (result.status === "rejected") console.error(result.reason); }); + if (newBadgeKey) { + redisObj.publish(`${process.env.INTERACTIVE_KEY}_RACE`, { + profileId, + badgeKey: newBadgeKey, + }); + } + return; } catch (error) { return new Error(error); From 06a3e6038ceceba7eb201790180bb9929e659849 Mon Sep 17 00:00:00 2001 From: Lina Date: Wed, 17 Dec 2025 20:30:44 -0800 Subject: [PATCH 12/20] add get leaderboard ensure leaderboard is up to date however it's accessed --- .../components/Leaderboard/Leaderboard.jsx | 41 +++++-- .../Leaderboard/LeaderboardScreen.jsx | 8 +- .../RaceCompletedScreen.jsx | 2 +- .../RaceInProgressScreen.jsx | 11 +- client/src/context/reducer.js | 8 +- client/src/context/types.js | 1 + client/src/pages/LeaderboardPage.jsx | 12 +- client/src/utils/completeRace.js | 22 ---- client/src/utils/index.js | 1 - server/controllers/handleCompleteRace.js | 20 ---- server/controllers/handleGetLeaderboard.js | 24 ++++ server/controllers/handleLoadGameState.js | 32 ++---- server/controllers/index.js | 2 +- server/routes.js | 4 +- server/utils/badges/awardBadge.js | 7 +- server/utils/checkpoints/finishLineEntered.js | 106 ++++++++---------- server/utils/formatLeaderboard.js | 23 ++++ server/utils/index.js | 1 + 18 files changed, 185 insertions(+), 140 deletions(-) delete mode 100644 client/src/utils/completeRace.js delete mode 100644 server/controllers/handleCompleteRace.js create mode 100644 server/controllers/handleGetLeaderboard.js create mode 100644 server/utils/formatLeaderboard.js diff --git a/client/src/components/Leaderboard/Leaderboard.jsx b/client/src/components/Leaderboard/Leaderboard.jsx index f82c80e..e2b00c7 100644 --- a/client/src/components/Leaderboard/Leaderboard.jsx +++ b/client/src/components/Leaderboard/Leaderboard.jsx @@ -1,16 +1,45 @@ -import { useContext } from "react"; +import { useContext, useEffect, useState } from "react"; // components -import { Footer } from "@components"; +import { Loading } from "@components"; // context import { GlobalDispatchContext, GlobalStateContext } from "@context/GlobalContext"; -import { SCREEN_MANAGER } from "@context/types"; +import { SET_LEADERBOARD, SET_ERROR } from "@context/types"; + +// utils +import { backendAPI, getErrorMessage } from "@utils"; export const Leaderboard = () => { const dispatch = useContext(GlobalDispatchContext); const { leaderboard, highScore } = useContext(GlobalStateContext); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + setIsLoading(true); + + const getLeaderboard = async () => { + await backendAPI + .get("/leaderboard") + .then((response) => { + dispatch({ type: SET_LEADERBOARD, payload: response.data }); + }) + .catch((error) => { + dispatch({ + type: SET_ERROR, + payload: { error: getErrorMessage("getting visitor inventory", error) }, + }); + }) + .finally(() => { + setIsLoading(false); + }); + }; + getLeaderboard(); + }, []); + + if (isLoading) return ; + return ( <>
@@ -51,12 +80,6 @@ export const Leaderboard = () => { )}
- -
- -
); }; diff --git a/client/src/components/Leaderboard/LeaderboardScreen.jsx b/client/src/components/Leaderboard/LeaderboardScreen.jsx index c8f81b8..7335281 100644 --- a/client/src/components/Leaderboard/LeaderboardScreen.jsx +++ b/client/src/components/Leaderboard/LeaderboardScreen.jsx @@ -1,7 +1,7 @@ import { useContext } from "react"; // components -import { BackButton, Leaderboard } from "@components"; +import { BackButton, Leaderboard, Footer } from "@components"; // context import { GlobalDispatchContext } from "@context/GlobalContext"; @@ -15,6 +15,12 @@ export const LeaderboardScreen = () => { dispatch({ type: SCREEN_MANAGER.SHOW_HOME_SCREEN })} /> + +
+ +
); }; diff --git a/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx b/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx index cd36836..51fb374 100644 --- a/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx +++ b/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx @@ -22,7 +22,7 @@ export const RaceCompletedScreen = () => { const eventSource = new EventSource(`/api/events?profileId=${profileId}`); eventSource.onmessage = function (event) { const newEvent = JSON.parse(event.data); - if (newEvent.badgeKey) setNewBadgeKey(newEvent.badgeKey); + if (newEvent.newBadgeName) setNewBadgeKey(newEvent.newBadgeName); }; eventSource.onerror = (event) => { console.error("Server Event error:", event); diff --git a/client/src/components/RaceInProgressScreen/RaceInProgressScreen.jsx b/client/src/components/RaceInProgressScreen/RaceInProgressScreen.jsx index 1bed21d..4df5b8c 100644 --- a/client/src/components/RaceInProgressScreen/RaceInProgressScreen.jsx +++ b/client/src/components/RaceInProgressScreen/RaceInProgressScreen.jsx @@ -6,9 +6,10 @@ import { Checkpoint, Footer, Loading } from "@components"; // context import { GlobalStateContext, GlobalDispatchContext } from "@context/GlobalContext"; +import { COMPLETE_RACE } from "@context/types"; // utils -import { cancelRace, completeRace } from "@utils"; +import { cancelRace } from "@utils"; export const RaceInProgressScreen = () => { const positiveAudioRef = useRef(null); @@ -123,7 +124,13 @@ export const RaceInProgressScreen = () => { if (allCompleted && !completeRaceCalledRef.current) { completeRaceCalledRef.current = true; successAudioRef.current.play(); - completeRace({ dispatch }); + + dispatch({ + type: COMPLETE_RACE, + payload: { + elapsedTime: currentFinishedElapsedTime, + }, + }); } }, [checkpoints, isFinishComplete, currentFinishedElapsedTime, dispatch]); diff --git a/client/src/context/reducer.js b/client/src/context/reducer.js index 6836ebb..322af78 100644 --- a/client/src/context/reducer.js +++ b/client/src/context/reducer.js @@ -8,6 +8,7 @@ import { RESET_GAME, SET_VISITOR_INVENTORY, SET_SCENE_DATA, + SET_LEADERBOARD, SET_ERROR, } from "./types"; @@ -70,7 +71,6 @@ const globalReducer = (state, action) => { ...state, screenManager: SCREEN_MANAGER.SHOW_RACE_COMPLETED_SCREEN, elapsedTime: payload.elapsedTime, - visitorInventory: payload.visitorInventory, error: "", }; case CANCEL_RACE: @@ -117,6 +117,12 @@ const globalReducer = (state, action) => { trackLastSwitchedDate: payload.trackLastSwitchedDate, error: "", }; + case SET_LEADERBOARD: + return { + ...state, + leaderboard: payload.leaderboard, + error: "", + }; case SET_ERROR: return { ...state, diff --git a/client/src/context/types.js b/client/src/context/types.js index c3691a5..2a814f5 100644 --- a/client/src/context/types.js +++ b/client/src/context/types.js @@ -7,6 +7,7 @@ export const CANCEL_RACE = "CANCEL_RACE"; export const RESET_GAME = "RESET_GAME"; export const SET_VISITOR_INVENTORY = "SET_VISITOR_INVENTORY"; export const SET_SCENE_DATA = "SET_SCENE_DATA"; +export const SET_LEADERBOARD = "SET_LEADERBOARD"; export const SCREEN_MANAGER = { SHOW_HOME_SCREEN: "SHOW_HOME_SCREEN", SHOW_LEADERBOARD_SCREEN: "SHOW_LEADERBOARD_SCREEN", diff --git a/client/src/pages/LeaderboardPage.jsx b/client/src/pages/LeaderboardPage.jsx index a7e2247..6740084 100644 --- a/client/src/pages/LeaderboardPage.jsx +++ b/client/src/pages/LeaderboardPage.jsx @@ -1,7 +1,8 @@ import { useContext, useState, useEffect } from "react"; +import { Link, useLocation } from "react-router-dom"; // components -import { PageContainer, Leaderboard } from "@components"; +import { PageContainer, Leaderboard, Footer } from "@components"; // context import { GlobalDispatchContext } from "@context/GlobalContext"; @@ -13,6 +14,9 @@ export const LeaderboardPage = () => { const dispatch = useContext(GlobalDispatchContext); const [loading, setLoading] = useState(true); + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + useEffect(() => { const fetchGameState = async () => { try { @@ -31,6 +35,12 @@ export const LeaderboardPage = () => { return ( + +
+ + + +
); }; diff --git a/client/src/utils/completeRace.js b/client/src/utils/completeRace.js deleted file mode 100644 index dc25e76..0000000 --- a/client/src/utils/completeRace.js +++ /dev/null @@ -1,22 +0,0 @@ -import { backendAPI, getErrorMessage } from "@utils"; -import { SET_ERROR, COMPLETE_RACE } from "@context/types"; - -export const completeRace = async ({ dispatch }) => { - try { - const result = await backendAPI.post("/race/complete-race"); - if (result.status === 200) { - dispatch({ - type: COMPLETE_RACE, - payload: { - elapsedTime: result.data.elapsedTime, - visitorInventory: result.data.visitorInventory, - }, - }); - } - } catch (error) { - dispatch({ - type: SET_ERROR, - payload: { error: getErrorMessage("conpleting", error) }, - }); - } -}; diff --git a/client/src/utils/index.js b/client/src/utils/index.js index 4182cae..6023644 100644 --- a/client/src/utils/index.js +++ b/client/src/utils/index.js @@ -1,6 +1,5 @@ export * from "./backendAPI.js"; export * from "./cancelRace.js"; -export * from "./completeRace.js"; export * from "./getErrorMessage.js"; export * from "./loadGameState.js"; export * from "./startRace.js"; diff --git a/server/controllers/handleCompleteRace.js b/server/controllers/handleCompleteRace.js deleted file mode 100644 index 2321dab..0000000 --- a/server/controllers/handleCompleteRace.js +++ /dev/null @@ -1,20 +0,0 @@ -import { errorHandler, getCredentials, getVisitor } from "../utils/index.js"; - -export const handleCompleteRace = async (req, res) => { - try { - const credentials = getCredentials(req.query); - - const { visitorProgress, visitorInventory } = await getVisitor(credentials); - const elapsedTime = visitorProgress.elapsedTime; - - return res.json({ success: true, elapsedTime, visitorInventory }); - } catch (error) { - return errorHandler({ - error, - functionName: "handleCompleteRace", - message: "Error completing race", - req, - res, - }); - } -}; diff --git a/server/controllers/handleGetLeaderboard.js b/server/controllers/handleGetLeaderboard.js new file mode 100644 index 0000000..7486823 --- /dev/null +++ b/server/controllers/handleGetLeaderboard.js @@ -0,0 +1,24 @@ +import { errorHandler, formatLeaderboard, getCredentials, World } from "../utils/index.js"; + +export const handleGetLeaderboard = async (req, res) => { + try { + const credentials = getCredentials(req.query); + const { urlSlug, sceneDropId } = credentials; + + const world = await World.create(urlSlug, { credentials }); + await world.fetchDataObject(); + const sceneData = world.dataObject?.[sceneDropId]; + + const leaderboard = await formatLeaderboard(sceneData?.leaderboard || {}); + + return res.json({ success: true, leaderboard }); + } catch (error) { + return errorHandler({ + error, + functionName: "handleGetLeaderboard", + message: "Error fetching leaderboard", + req, + res, + }); + } +}; diff --git a/server/controllers/handleLoadGameState.js b/server/controllers/handleLoadGameState.js index 2adeb22..eaffae7 100644 --- a/server/controllers/handleLoadGameState.js +++ b/server/controllers/handleLoadGameState.js @@ -1,4 +1,11 @@ -import { World, errorHandler, getCredentials, getInventoryItems, getVisitor } from "../utils/index.js"; +import { + World, + errorHandler, + formatLeaderboard, + getCredentials, + getInventoryItems, + getVisitor, +} from "../utils/index.js"; import { TRACKS } from "../constants.js"; export const handleLoadGameState = async (req, res) => { @@ -30,6 +37,7 @@ export const handleLoadGameState = async (req, res) => { }; shouldUpdateWorldDataObject = true; } else if (sceneData.profiles) { + // Migrate old leaderboard format to new format let leaderboard = {}; for (const profileId in sceneData.profiles) { const { username, highscore } = sceneData.profiles[profileId]; @@ -60,25 +68,7 @@ export const handleLoadGameState = async (req, res) => { const { visitor, visitorProgress, visitorInventory } = await getVisitor(credentials, true); const { checkpoints, highScore, startTimestamp } = visitorProgress; - const leaderboard = []; - for (const profileId in sceneData.leaderboard) { - const data = sceneData.leaderboard[profileId]; - - const [displayName, highScore] = data.split("|"); - - leaderboard.push({ - displayName, - highScore, - }); - } - - // Sort leaderboard by highScore as time string (HH:MM:SS) - const timeToSeconds = (t) => { - if (!t) return Infinity; - const [h = "0", m = "0", s = "0"] = t.split(":"); - return parseInt(h, 10) * 3600 + parseInt(m, 10) * 60 + parseInt(s, 10); - }; - leaderboard.sort((a, b) => timeToSeconds(a.highScore) - timeToSeconds(b.highScore)).slice(0, 20); + const leaderboardArray = await formatLeaderboard(sceneData.leaderboard); const { badges } = await getInventoryItems(credentials); @@ -87,7 +77,7 @@ export const handleLoadGameState = async (req, res) => { elapsedTimeInSeconds: startTimestamp ? Math.floor((now - startTimestamp) / 1000) : 0, highScore, isAdmin: visitor.isAdmin, - leaderboard, + leaderboard: leaderboardArray, numberOfCheckpoints: sceneData.numberOfCheckpoints, startTimestamp, success: true, diff --git a/server/controllers/index.js b/server/controllers/index.js index be402b7..75b2359 100644 --- a/server/controllers/index.js +++ b/server/controllers/index.js @@ -1,8 +1,8 @@ export * from "./handleCancelRace.js"; export * from "./handleCheckpointEntered.js"; -export * from "./handleCompleteRace.js"; export * from "./handleRaceStart.js"; export * from "./handleGetEvents.js"; +export * from "./handleGetLeaderboard.js"; export * from "./handleGetVisitorInventory.js"; export * from "./handleLoadGameState.js"; export * from "./handleResetGame.js"; diff --git a/server/routes.js b/server/routes.js index 49ab2c7..ea68b5c 100644 --- a/server/routes.js +++ b/server/routes.js @@ -3,8 +3,8 @@ import express from "express"; import { handleCancelRace, handleCheckpointEntered, - handleCompleteRace, handleGetEvents, + handleGetLeaderboard, handleGetVisitorInventory, handleLoadGameState, handleRaceStart, @@ -39,13 +39,13 @@ router.get("/system/health", (req, res) => { }); router.get("/visitor-inventory", handleGetVisitorInventory); +router.get("/leaderboard", handleGetLeaderboard); // Race router.post("/race/game-state", handleLoadGameState); router.post("/race/start-race", handleRaceStart); router.post("/race/checkpoint-entered", handleCheckpointEntered); router.post("/race/cancel-race", handleCancelRace); -router.post("/race/complete-race", handleCompleteRace); router.post("/race/reset-game", handleResetGame); router.post("/race/switch-track", handleSwitchTrack); diff --git a/server/utils/badges/awardBadge.js b/server/utils/badges/awardBadge.js index 8fa9097..055209f 100644 --- a/server/utils/badges/awardBadge.js +++ b/server/utils/badges/awardBadge.js @@ -1,6 +1,6 @@ import { Ecosystem } from "../topiaInit.js"; -export const awardBadge = async ({ credentials, visitor, visitorInventory, badgeName }) => { +export const awardBadge = async ({ credentials, visitor, visitorInventory, badgeName, redisObj, profileId }) => { try { if (visitorInventory[badgeName]) return { success: true }; @@ -12,6 +12,11 @@ export const awardBadge = async ({ credentials, visitor, visitorInventory, badge await visitor.grantInventoryItem(inventoryItem, 1); + redisObj.publish(`${process.env.INTERACTIVE_KEY}_RACE`, { + profileId, + newBadgeName: badgeName, + }); + return { success: true }; } catch (error) { return new Error(error); diff --git a/server/utils/checkpoints/finishLineEntered.js b/server/utils/checkpoints/finishLineEntered.js index e10f4eb..51789df 100644 --- a/server/utils/checkpoints/finishLineEntered.js +++ b/server/utils/checkpoints/finishLineEntered.js @@ -14,7 +14,6 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr const { assetId, displayName, profileId, sceneDropId, urlSlug } = credentials; const promises = []; - let newBadgeKey; const world = World.create(urlSlug, { credentials }); await world.fetchDataObject(); @@ -93,14 +92,14 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr // Award Race Rookie badge if this is the visitor's first high score if (!visitorProgress.highScore) { - newBadgeKey = "Race Rookie"; promises.push( - awardBadge({ credentials, visitor, visitorInventory, badgeName: "Race Rookie" }).catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error awarding Race Rookie badge", - }), + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Race Rookie", redisObj, profileId }).catch( + (error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Race Rookie badge", + }), ), ); } @@ -108,14 +107,14 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr // Award Top 3 Racer badge if newHighScore is in top 3 of leaderboard const shouldGetTop3Badge = await isNewHighScoreTop3(raceObject.leaderboard, newHighScore); if (shouldGetTop3Badge) { - newBadgeKey = "Top 3 Racer"; promises.push( - awardBadge({ credentials, visitor, visitorInventory, badgeName: "Top 3 Racer" }).catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error awarding Top 3 Racer badge", - }), + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Top 3 Racer", redisObj, profileId }).catch( + (error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Top 3 Racer badge", + }), ), ); } @@ -124,64 +123,64 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr const [min, sec, mili] = currentElapsedTime.split(":").map(Number); const totalSeconds = min * 60 + sec + mili / 100; if (totalSeconds < 30) { - newBadgeKey = "Speed Demon"; promises.push( - awardBadge({ credentials, visitor, visitorInventory, badgeName: "Speed Demon" }).catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error awarding Speed Demon badge", - }), + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Speed Demon", redisObj, profileId }).catch( + (error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Speed Demon badge", + }), ), ); } else if (totalSeconds > 120) { - newBadgeKey = "Slow & Steady"; promises.push( - awardBadge({ credentials, visitor, visitorInventory, badgeName: "Slow & Steady" }).catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error awarding Slow & Steady badge", - }), + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Slow & Steady", redisObj, profileId }).catch( + (error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Slow & Steady badge", + }), ), ); } // Award Race Pro badge if visitor has completed 100 races or Race Expert badge if visitor has completed 1000 races if (visitor.dataObject.racesCompleted + 1 === 100) { - newBadgeKey = "Race Pro"; promises.push( - awardBadge({ credentials, visitor, visitorInventory, badgeName: "Race Pro" }).catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error awarding Race Pro badge", - }), + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Race Pro", redisObj, profileId }).catch( + (error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Race Pro badge", + }), ), ); } else if (visitor.dataObject.racesCompleted + 1 === 1000) { - newBadgeKey = "Race Expert"; promises.push( - awardBadge({ credentials, visitor, visitorInventory, badgeName: "Race Expert" }).catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error awarding Race Expert badge", - }), + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Race Expert", redisObj, profileId }).catch( + (error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Race Expert badge", + }), ), ); } // Award Never Give Up badge if visitor completed the race after previously entering a wrong checkpoint if (wasWrongCheckpointEntered) { - newBadgeKey = "Never Give Up"; promises.push( - awardBadge({ credentials, visitor, visitorInventory, badgeName: "Never Give Up" }).catch((error) => - errorHandler({ - error, - functionName: "finishLineEntered", - message: "Error awarding Never Give Up badge", - }), + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Never Give Up", redisObj, profileId }).catch( + (error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Never Give Up badge", + }), ), ); } @@ -191,13 +190,6 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr if (result.status === "rejected") console.error(result.reason); }); - if (newBadgeKey) { - redisObj.publish(`${process.env.INTERACTIVE_KEY}_RACE`, { - profileId, - badgeKey: newBadgeKey, - }); - } - return; } catch (error) { return new Error(error); diff --git a/server/utils/formatLeaderboard.js b/server/utils/formatLeaderboard.js new file mode 100644 index 0000000..687bedd --- /dev/null +++ b/server/utils/formatLeaderboard.js @@ -0,0 +1,23 @@ +export const formatLeaderboard = async (leaderboard) => { + const leaderboardArray = []; + for (const profileId in leaderboard) { + const data = leaderboard[profileId]; + + const [displayName, highScore] = data.split("|"); + + leaderboardArray.push({ + displayName, + highScore, + }); + } + + // Sort leaderboard by highScore as time string (HH:MM:SS) + const timeToSeconds = (t) => { + if (!t) return Infinity; + const [h = "0", m = "0", s = "0"] = t.split(":"); + return parseInt(h, 10) * 3600 + parseInt(m, 10) * 60 + parseInt(s, 10); + }; + leaderboardArray.sort((a, b) => timeToSeconds(a.highScore) - timeToSeconds(b.highScore)).slice(0, 20); + + return leaderboardArray; +}; diff --git a/server/utils/index.js b/server/utils/index.js index 0390f3b..74989a8 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -4,6 +4,7 @@ export * from "./visitors/index.js"; export * from "./addNewRowToGoogleSheets.js"; export * from "./cleanReturnPayload.js"; export * from "./errorHandler.js"; +export * from "./formatLeaderboard.js"; export * from "./getCredentials.js"; export * from "./getVersion.js"; export * from "./topiaInit.js"; From f30214a5329235ff7a5a79baa7d409236c2cc4b0 Mon Sep 17 00:00:00 2001 From: Lina Date: Thu, 15 Jan 2026 10:10:19 -0800 Subject: [PATCH 13/20] add new images --- client/src/components/Leaderboard/Leaderboard.jsx | 6 +++++- client/src/components/NewGameScreen/NewGameScreen.jsx | 3 +-- .../components/RaceCompletedScreen/RaceCompletedScreen.jsx | 7 ++++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/client/src/components/Leaderboard/Leaderboard.jsx b/client/src/components/Leaderboard/Leaderboard.jsx index e2b00c7..db116bf 100644 --- a/client/src/components/Leaderboard/Leaderboard.jsx +++ b/client/src/components/Leaderboard/Leaderboard.jsx @@ -43,7 +43,11 @@ export const Leaderboard = () => { return ( <>
-
🏆
+ leaderboard

Leaderboard

diff --git a/client/src/components/NewGameScreen/NewGameScreen.jsx b/client/src/components/NewGameScreen/NewGameScreen.jsx index 1b41bc8..3f184b6 100644 --- a/client/src/components/NewGameScreen/NewGameScreen.jsx +++ b/client/src/components/NewGameScreen/NewGameScreen.jsx @@ -1,7 +1,6 @@ import { useContext } from "react"; // components -import racingMap from "../../assets/racingMap.png"; import { Footer, Tabs } from "@components"; // context @@ -23,7 +22,7 @@ export const NewGameScreen = () => { <>
- racing map + racing map
How to Play
    diff --git a/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx b/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx index 51fb374..2dea054 100644 --- a/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx +++ b/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx @@ -43,7 +43,12 @@ export const RaceCompletedScreen = () => {

    Congratulations!

    -
    🏁
    + checkered flag

    Your Time

    {elapsedTime} From d58b698b5c60793da4fc9921cd371b812a6ceacf Mon Sep 17 00:00:00 2001 From: Lina Date: Thu, 15 Jan 2026 11:47:40 -0800 Subject: [PATCH 14/20] add track specific badges --- .../components/BadgesScreen/BadgesScreen.jsx | 3 ++- .../SwitchRace/SwitchTrackScreen.jsx | 2 +- server/controllers/handleSwitchTrack.js | 6 ++++-- server/utils/checkpoints/finishLineEntered.js | 18 ++++++++++++++++++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/client/src/components/BadgesScreen/BadgesScreen.jsx b/client/src/components/BadgesScreen/BadgesScreen.jsx index 3e5a5b1..ad7aafd 100644 --- a/client/src/components/BadgesScreen/BadgesScreen.jsx +++ b/client/src/components/BadgesScreen/BadgesScreen.jsx @@ -52,7 +52,8 @@ export const BadgesScreen = () => {
    {Object.values(badges).map((badge) => { const hasBadge = visitorInventory && Object.keys(visitorInventory).includes(badge.name); - const style = hasBadge ? {} : { filter: "grayscale(1)" }; + const style = { width: "90px" }; + if (!hasBadge) style.filter = "grayscale(1)"; return (
    {badge.name} diff --git a/client/src/components/SwitchRace/SwitchTrackScreen.jsx b/client/src/components/SwitchRace/SwitchTrackScreen.jsx index ab88f9a..5607eb0 100644 --- a/client/src/components/SwitchRace/SwitchTrackScreen.jsx +++ b/client/src/components/SwitchRace/SwitchTrackScreen.jsx @@ -33,7 +33,7 @@ export const SwitchTrackScreen = () => { setAreAllButtonsDisabled(true); await backendAPI - .post(`/race/switch-track?trackSceneId=${selectedTrack.sceneId}`) + .post("/race/switch-track", { selectedTrack }) .then((response) => { const { leaderboard, numberOfCheckpoints, trackLastSwitchedDate } = response.data.sceneData; diff --git a/server/controllers/handleSwitchTrack.js b/server/controllers/handleSwitchTrack.js index 9640850..a82a964 100644 --- a/server/controllers/handleSwitchTrack.js +++ b/server/controllers/handleSwitchTrack.js @@ -12,7 +12,8 @@ export const handleSwitchTrack = async (req, res) => { try { const credentials = getCredentials(req.query); const { assetId, profileId, urlSlug, sceneDropId } = credentials; - const { trackSceneId } = req.query; + const { selectedTrack } = req.body; + const { sceneId, name } = selectedTrack; const world = await World.create(urlSlug, { credentials }); const { visitor } = await getVisitor(credentials); @@ -54,7 +55,7 @@ export const handleSwitchTrack = async (req, res) => { await world.dropScene({ allowNonAdmins: true, - sceneId: trackSceneId, + sceneId, position, sceneDropId, }); @@ -65,6 +66,7 @@ export const handleSwitchTrack = async (req, res) => { }); const sceneData = { + trackName: name, numberOfCheckpoints: numberOfCheckpoints?.length, leaderboard: {}, position, diff --git a/server/utils/checkpoints/finishLineEntered.js b/server/utils/checkpoints/finishLineEntered.js index 51789df..f1f27ae 100644 --- a/server/utils/checkpoints/finishLineEntered.js +++ b/server/utils/checkpoints/finishLineEntered.js @@ -185,6 +185,24 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWr ); } + // Award Track Completion badge for specific track by name if available + promises.push( + awardBadge({ + credentials, + visitor, + visitorInventory, + badgeName: raceObject.trackName, + redisObj, + profileId, + }).catch((error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: `Error awarding ${raceObject.trackName} completion badge`, + }), + ), + ); + const results = await Promise.allSettled(promises); results.forEach((result) => { if (result.status === "rejected") console.error(result.reason); From 21edc4eab75a3ee354f14412a046b61abe4d41b1 Mon Sep 17 00:00:00 2001 From: Lina Date: Wed, 21 Jan 2026 18:47:45 -0800 Subject: [PATCH 15/20] cleanup badges layout --- client/src/components/BadgesScreen/BadgesScreen.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/BadgesScreen/BadgesScreen.jsx b/client/src/components/BadgesScreen/BadgesScreen.jsx index ad7aafd..ccde46e 100644 --- a/client/src/components/BadgesScreen/BadgesScreen.jsx +++ b/client/src/components/BadgesScreen/BadgesScreen.jsx @@ -49,13 +49,13 @@ export const BadgesScreen = () => { Badges

    -
    +
    {Object.values(badges).map((badge) => { const hasBadge = visitorInventory && Object.keys(visitorInventory).includes(badge.name); const style = { width: "90px" }; if (!hasBadge) style.filter = "grayscale(1)"; return ( -
    +
    {badge.name} {badge.name}
    From 903d83f5bfce53ac8c40e9e9979c8b4c99e82e23 Mon Sep 17 00:00:00 2001 From: Lina Date: Tue, 27 Jan 2026 10:39:07 -0800 Subject: [PATCH 16/20] add toasts and try/catch to redis calls --- .../components/BadgesScreen/BadgesScreen.jsx | 23 +++--- server/controllers/handleCheckpointEntered.js | 75 +++++++++++++++---- server/controllers/handleGetEvents.js | 10 ++- server/controllers/handleRaceStart.js | 10 ++- server/utils/badges/awardBadge.js | 23 +++++- 5 files changed, 108 insertions(+), 33 deletions(-) diff --git a/client/src/components/BadgesScreen/BadgesScreen.jsx b/client/src/components/BadgesScreen/BadgesScreen.jsx index ccde46e..09d2651 100644 --- a/client/src/components/BadgesScreen/BadgesScreen.jsx +++ b/client/src/components/BadgesScreen/BadgesScreen.jsx @@ -50,17 +50,18 @@ export const BadgesScreen = () => {
    - {Object.values(badges).map((badge) => { - const hasBadge = visitorInventory && Object.keys(visitorInventory).includes(badge.name); - const style = { width: "90px" }; - if (!hasBadge) style.filter = "grayscale(1)"; - return ( -
    - {badge.name} - {badge.name} -
    - ); - })} + {badges && + Object.values(badges).map((badge) => { + const hasBadge = visitorInventory && Object.keys(visitorInventory).includes(badge.name); + const style = { width: "90px" }; + if (!hasBadge) style.filter = "grayscale(1)"; + return ( +
    + {badge.name} + {badge.name} +
    + ); + })}
    diff --git a/server/controllers/handleCheckpointEntered.js b/server/controllers/handleCheckpointEntered.js index 8235cc8..af9bcd5 100644 --- a/server/controllers/handleCheckpointEntered.js +++ b/server/controllers/handleCheckpointEntered.js @@ -31,10 +31,18 @@ export const handleCheckpointEntered = async (req, res) => { if (checkpointNumber !== 0) { if (checkpointNumber > 1 && !cachedCheckpoints[checkpointNumber - 2]) { - redisObj.publish(channel, { - profileId, - checkpointsCompleted: cachedCheckpoints, - }); + try { + await redisObj.publish(channel, { + profileId, + checkpointsCompleted: cachedCheckpoints, + }); + } catch (error) { + errorHandler({ + error, + functionName: "handleCheckpointEntered", + message: "Error publishing wrong checkpoint entered to redis", + }); + } const visitor = await Visitor.create(visitorId, urlSlug, { credentials }); await visitor .fireToast({ @@ -50,25 +58,60 @@ export const handleCheckpointEntered = async (req, res) => { }), ); - redisObj.set(profileId, JSON.stringify({ checkpoints: cachedCheckpoints, wasWrongCheckpointEntered: true })); + try { + await redisObj.set( + profileId, + JSON.stringify({ checkpoints: cachedCheckpoints, wasWrongCheckpointEntered: true }), + ); + } catch (error) { + errorHandler({ + error, + functionName: "handleCheckpointEntered", + message: "Error updating object in redis when wrong checkpoint entered", + }); + } return; } else { - redisObj.publish(channel, { - profileId, - checkpointNumber, - currentRaceFinishedElapsedTime: null, - }); + try { + await redisObj.publish(channel, { + profileId, + checkpointNumber, + currentRaceFinishedElapsedTime: null, + }); + } catch (error) { + errorHandler({ + error, + functionName: "handleCheckpointEntered", + message: "Error publishing checkpoint entered to redis", + }); + } cachedCheckpoints[checkpointNumber - 1] = true; - redisObj.set(profileId, JSON.stringify({ checkpoints: cachedCheckpoints, wasWrongCheckpointEntered })); + try { + await redisObj.set(profileId, JSON.stringify({ checkpoints: cachedCheckpoints, wasWrongCheckpointEntered })); + } catch (error) { + errorHandler({ + error, + functionName: "handleCheckpointEntered", + message: "Error updating object in redis when checkpoint entered", + }); + } } } if (checkpointNumber === 0) { - redisObj.publish(channel, { - profileId, - checkpointNumber, - currentRaceFinishedElapsedTime: currentElapsedTime, - }); + try { + await redisObj.publish(channel, { + profileId, + checkpointNumber, + currentRaceFinishedElapsedTime: currentElapsedTime, + }); + } catch (error) { + errorHandler({ + error, + functionName: "handleCheckpointEntered", + message: "Error publishing finish line entered to redis", + }); + } const result = await finishLineEntered({ credentials, currentElapsedTime, wasWrongCheckpointEntered, redisObj }); if (result instanceof Error) throw result; } else { diff --git a/server/controllers/handleGetEvents.js b/server/controllers/handleGetEvents.js index 1a6dd4b..cfede78 100644 --- a/server/controllers/handleGetEvents.js +++ b/server/controllers/handleGetEvents.js @@ -12,7 +12,15 @@ export const handleGetEvents = async (req, res) => { "Cache-Control": "no-cache", }); - redisObj.addConn({ res, lastHeartbeatTime: Date.now() }); + try { + await redisObj.addConn({ res, lastHeartbeatTime: Date.now() }); + } catch (error) { + errorHandler({ + error, + functionName: "handleGetEvents", + message: "Error adding connection to redis", + }); + } res.write(`retry: 5000\ndata: ${JSON.stringify({ success: true })}\n\n`); } catch (error) { diff --git a/server/controllers/handleRaceStart.js b/server/controllers/handleRaceStart.js index 74840a0..3e77c98 100644 --- a/server/controllers/handleRaceStart.js +++ b/server/controllers/handleRaceStart.js @@ -16,7 +16,15 @@ export const handleRaceStart = async (req, res) => { const { identityId, displayName } = req.query; const startTimestamp = Date.now(); - redisObj.set(profileId, JSON.stringify({ checkpoints: { 0: false }, wasWrongCheckpointEntered: false })); + try { + await redisObj.set(profileId, JSON.stringify({ checkpoints: { 0: false }, wasWrongCheckpointEntered: false })); + } catch (error) { + errorHandler({ + error, + functionName: "handleRaceStart", + message: "Error updating object in redis when race started", + }); + } const world = World.create(urlSlug, { credentials }); world.triggerActivity({ type: WorldActivityType.GAME_ON, assetId }).catch((error) => diff --git a/server/utils/badges/awardBadge.js b/server/utils/badges/awardBadge.js index 055209f..d36c9c4 100644 --- a/server/utils/badges/awardBadge.js +++ b/server/utils/badges/awardBadge.js @@ -12,10 +12,25 @@ export const awardBadge = async ({ credentials, visitor, visitorInventory, badge await visitor.grantInventoryItem(inventoryItem, 1); - redisObj.publish(`${process.env.INTERACTIVE_KEY}_RACE`, { - profileId, - newBadgeName: badgeName, - }); + 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.`)); + + try { + await redisObj.publish(`${process.env.INTERACTIVE_KEY}_RACE`, { + profileId, + newBadgeName: badgeName, + }); + } catch (error) { + errorHandler({ + error, + functionName: "awardBadge", + message: "Error publishing new badge awarded to redis", + }); + } return { success: true }; } catch (error) { From 6b2703b29963359fce2268684ac0dc9c190f9cee Mon Sep 17 00:00:00 2001 From: Lina Date: Wed, 28 Jan 2026 10:10:11 -0800 Subject: [PATCH 17/20] update highScore logic --- client/src/components/Leaderboard/Leaderboard.jsx | 2 +- client/src/context/reducer.js | 1 + package-lock.json | 8 ++++---- package.json | 2 +- server/controllers/handleGetLeaderboard.js | 6 +++--- server/controllers/handleLoadGameState.js | 8 ++++---- server/utils/formatLeaderboard.js | 7 +++++-- 7 files changed, 19 insertions(+), 15 deletions(-) diff --git a/client/src/components/Leaderboard/Leaderboard.jsx b/client/src/components/Leaderboard/Leaderboard.jsx index db116bf..4bffff2 100644 --- a/client/src/components/Leaderboard/Leaderboard.jsx +++ b/client/src/components/Leaderboard/Leaderboard.jsx @@ -53,7 +53,7 @@ export const Leaderboard = () => {

    Personal Best

    -

    {highScore || "No highScore available"}

    +

    {highScore || "No high score available"}

    {leaderboard?.length > 0 ? ( diff --git a/client/src/context/reducer.js b/client/src/context/reducer.js index 322af78..f402e30 100644 --- a/client/src/context/reducer.js +++ b/client/src/context/reducer.js @@ -121,6 +121,7 @@ const globalReducer = (state, action) => { return { ...state, leaderboard: payload.leaderboard, + highScore: payload.highScore, error: "", }; case SET_ERROR: diff --git a/package-lock.json b/package-lock.json index 1c28d85..80657a2 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.3", "axios": "^1.6.7", "body-parser": "^1.20.2", "concurrently": "^8.2.2", @@ -1018,9 +1018,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.3", + "resolved": "https://registry.npmjs.org/@rtsdk/topia/-/topia-0.19.3.tgz", + "integrity": "sha512-Qi+jx49kUDF+ZbT2Z2Ui3n7JjlDpxpMBZKPkoOVqqFRiJMwzmMqZ8cy9B8uVIA4MtrXPFAe6ftP9Og3DaxPFqg==" }, "node_modules/@sdk-race/client": { "resolved": "client", diff --git a/package.json b/package.json index c47b456..580977d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "dependencies": { "@googleapis/sheets": "^7.0.0", - "@rtsdk/topia": "^0.18.3", + "@rtsdk/topia": "^0.19.3", "axios": "^1.6.7", "body-parser": "^1.20.2", "concurrently": "^8.2.2", diff --git a/server/controllers/handleGetLeaderboard.js b/server/controllers/handleGetLeaderboard.js index 7486823..f6b31f7 100644 --- a/server/controllers/handleGetLeaderboard.js +++ b/server/controllers/handleGetLeaderboard.js @@ -3,15 +3,15 @@ import { errorHandler, formatLeaderboard, getCredentials, World } from "../utils export const handleGetLeaderboard = async (req, res) => { try { const credentials = getCredentials(req.query); - const { urlSlug, sceneDropId } = credentials; + const { profileId, urlSlug, sceneDropId } = credentials; const world = await World.create(urlSlug, { credentials }); await world.fetchDataObject(); const sceneData = world.dataObject?.[sceneDropId]; - const leaderboard = await formatLeaderboard(sceneData?.leaderboard || {}); + const { leaderboardArray, highScore } = await formatLeaderboard(sceneData.leaderboard, profileId); - return res.json({ success: true, leaderboard }); + return res.json({ success: true, leaderboard: leaderboardArray, highScore }); } catch (error) { return errorHandler({ error, diff --git a/server/controllers/handleLoadGameState.js b/server/controllers/handleLoadGameState.js index eaffae7..417dc83 100644 --- a/server/controllers/handleLoadGameState.js +++ b/server/controllers/handleLoadGameState.js @@ -11,7 +11,7 @@ import { TRACKS } from "../constants.js"; export const handleLoadGameState = async (req, res) => { try { const credentials = getCredentials(req.query); - const { urlSlug, sceneDropId } = credentials; + const { profileId, urlSlug, sceneDropId } = credentials; const now = Date.now(); const world = await World.create(urlSlug, { credentials }); @@ -65,10 +65,10 @@ export const handleLoadGameState = async (req, res) => { ); } - const { visitor, visitorProgress, visitorInventory } = await getVisitor(credentials, true); - const { checkpoints, highScore, startTimestamp } = visitorProgress; + const { leaderboardArray, highScore } = await formatLeaderboard(sceneData.leaderboard, profileId); - const leaderboardArray = await formatLeaderboard(sceneData.leaderboard); + const { visitor, visitorProgress, visitorInventory } = await getVisitor(credentials, true); + let { checkpoints, startTimestamp } = visitorProgress; const { badges } = await getInventoryItems(credentials); diff --git a/server/utils/formatLeaderboard.js b/server/utils/formatLeaderboard.js index 687bedd..e2a0591 100644 --- a/server/utils/formatLeaderboard.js +++ b/server/utils/formatLeaderboard.js @@ -1,4 +1,4 @@ -export const formatLeaderboard = async (leaderboard) => { +export const formatLeaderboard = async (leaderboard, profileId) => { const leaderboardArray = []; for (const profileId in leaderboard) { const data = leaderboard[profileId]; @@ -19,5 +19,8 @@ export const formatLeaderboard = async (leaderboard) => { }; leaderboardArray.sort((a, b) => timeToSeconds(a.highScore) - timeToSeconds(b.highScore)).slice(0, 20); - return leaderboardArray; + let highScore; + if (Object.keys(leaderboard).includes(profileId)) highScore = leaderboard[profileId].split("|")[1]; + + return { leaderboardArray, highScore }; }; From f400e589a78186367e9c0723f29b04c13f945c4e Mon Sep 17 00:00:00 2001 From: Lina Date: Wed, 28 Jan 2026 10:18:31 -0800 Subject: [PATCH 18/20] update badge modal --- .../RaceCompletedScreen.jsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx b/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx index 2dea054..fa387fe 100644 --- a/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx +++ b/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx @@ -12,26 +12,26 @@ export const RaceCompletedScreen = () => { const dispatch = useContext(GlobalDispatchContext); const { elapsedTime, badges } = useContext(GlobalStateContext); - const [newBadgeKey, setNewBadgeKey] = useState(); + const [newBadgeKey, setNewBadgeKey] = useState("Race Expert"); const [searchParams] = useSearchParams(); const profileId = searchParams.get("profileId"); - useEffect(() => { - if (profileId) { - const eventSource = new EventSource(`/api/events?profileId=${profileId}`); - eventSource.onmessage = function (event) { - const newEvent = JSON.parse(event.data); - if (newEvent.newBadgeName) setNewBadgeKey(newEvent.newBadgeName); - }; - eventSource.onerror = (event) => { - console.error("Server Event error:", event); - }; - return () => { - eventSource.close(); - }; - } - }, [profileId]); + // useEffect(() => { + // if (profileId) { + // const eventSource = new EventSource(`/api/events?profileId=${profileId}`); + // eventSource.onmessage = function (event) { + // const newEvent = JSON.parse(event.data); + // if (newEvent.newBadgeName) setNewBadgeKey(newEvent.newBadgeName); + // }; + // eventSource.onerror = (event) => { + // console.error("Server Event error:", event); + // }; + // return () => { + // eventSource.close(); + // }; + // } + // }, [profileId]); return ( <> @@ -63,7 +63,7 @@ export const RaceCompletedScreen = () => { - {newBadgeKey && {}} />} + {newBadgeKey && setNewBadgeKey(null)} />} ); }; From ae6ed759873254f34ba90abfcde9126a7cb91364 Mon Sep 17 00:00:00 2001 From: Lina Date: Wed, 28 Jan 2026 14:08:31 -0800 Subject: [PATCH 19/20] undo comments for testing --- .../RaceCompletedScreen.jsx | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx b/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx index fa387fe..08b972b 100644 --- a/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx +++ b/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx @@ -12,26 +12,26 @@ export const RaceCompletedScreen = () => { const dispatch = useContext(GlobalDispatchContext); const { elapsedTime, badges } = useContext(GlobalStateContext); - const [newBadgeKey, setNewBadgeKey] = useState("Race Expert"); + const [newBadgeKey, setNewBadgeKey] = useState(); const [searchParams] = useSearchParams(); const profileId = searchParams.get("profileId"); - // useEffect(() => { - // if (profileId) { - // const eventSource = new EventSource(`/api/events?profileId=${profileId}`); - // eventSource.onmessage = function (event) { - // const newEvent = JSON.parse(event.data); - // if (newEvent.newBadgeName) setNewBadgeKey(newEvent.newBadgeName); - // }; - // eventSource.onerror = (event) => { - // console.error("Server Event error:", event); - // }; - // return () => { - // eventSource.close(); - // }; - // } - // }, [profileId]); + useEffect(() => { + if (profileId) { + const eventSource = new EventSource(`/api/events?profileId=${profileId}`); + eventSource.onmessage = function (event) { + const newEvent = JSON.parse(event.data); + if (newEvent.newBadgeName) setNewBadgeKey(newEvent.newBadgeName); + }; + eventSource.onerror = (event) => { + console.error("Server Event error:", event); + }; + return () => { + eventSource.close(); + }; + } + }, [profileId]); return ( <> From 001cef0bd9f4ae0b5ebee3ee814ccf3d530ff696 Mon Sep 17 00:00:00 2001 From: Terraform Date: Tue, 3 Feb 2026 20:51:12 +0000 Subject: [PATCH 20/20] 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 }}