diff --git a/.github/workflows/aws_auto_release.yml b/.github/workflows/aws_auto_release.yml new file mode 100644 index 0000000..c265155 --- /dev/null +++ b/.github/workflows/aws_auto_release.yml @@ -0,0 +1,239 @@ +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 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 "[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 + authorized_users+=("$user") + echo " - CODEOWNER: $user" + done + else + echo "[WARN] No CODEOWNERS file found" + fi + + # Get repository collaborators with admin/maintain permissions using GitHub API + echo "[CHECK] 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 "[OK] 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 "[OK] 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 "[OK] User $merged_by is authorized as organization owner" + fi + fi + + echo "is_authorized=$is_authorized" >> $GITHUB_OUTPUT + + if [[ "$is_authorized" == "false" ]]; then + 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 "[SUCCESS] 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) }}' + 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.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.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" + 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.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 + 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* + 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 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..09d2651 --- /dev/null +++ b/client/src/components/BadgesScreen/BadgesScreen.jsx @@ -0,0 +1,71 @@ +import { useContext, useEffect, useState } from "react"; + +// components +import { BackButton, Loading } from "@components"; + +// context +import { GlobalDispatchContext, GlobalStateContext } from "@context/GlobalContext"; +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 })} /> + +
+

+ Badges +

+ +
+ {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} +
+ ); + })} +
+
+ + ); +}; + +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..4bffff2 --- /dev/null +++ b/client/src/components/Leaderboard/Leaderboard.jsx @@ -0,0 +1,91 @@ +import { useContext, useEffect, useState } from "react"; + +// components +import { Loading } from "@components"; + +// context +import { GlobalDispatchContext, GlobalStateContext } from "@context/GlobalContext"; +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 ( + <> +
+ leaderboard +

+ Leaderboard +

+
+

Personal Best

+

{highScore || "No high score 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..7335281 --- /dev/null +++ b/client/src/components/Leaderboard/LeaderboardScreen.jsx @@ -0,0 +1,28 @@ +import { useContext } from "react"; + +// components +import { BackButton, Leaderboard, Footer } 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..3f184b6 100644 --- a/client/src/components/NewGameScreen/NewGameScreen.jsx +++ b/client/src/components/NewGameScreen/NewGameScreen.jsx @@ -1,52 +1,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 +
+ + 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/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 e2323bd..08b972b 100644 --- a/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx +++ b/client/src/components/RaceCompletedScreen/RaceCompletedScreen.jsx @@ -1,39 +1,69 @@ -import { useContext } from "react"; -import { Link } from "react-router-dom"; +import { useContext, useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; // components -import Footer from "@components/Shared/Footer"; +import { Footer, NewBadgeModal, 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); + 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.newBadgeName) setNewBadgeKey(newEvent.newBadgeName); + }; + eventSource.onerror = (event) => { + console.error("Server Event error:", event); + }; + return () => { + eventSource.close(); + }; + } + }, [profileId]); return ( <> -
-

🏆 Congratulations!

-

You have successfully completed the race.

-

Elapsed Time: {elapsedTime}

+
+ + +
+
+

+ Congratulations! +

+ checkered flag +

Your Time

+

+ {elapsedTime} +

+
+
+
- - - -
+ + {newBadgeKey && setNewBadgeKey(null)} />} ); }; 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..4df5b8c 100644 --- a/client/src/components/RaceInProgressScreen/RaceInProgressScreen.jsx +++ b/client/src/components/RaceInProgressScreen/RaceInProgressScreen.jsx @@ -2,17 +2,16 @@ 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"; +import { COMPLETE_RACE } from "@context/types"; // utils -import { cancelRace, completeRace } from "@utils"; +import { cancelRace } from "@utils"; -const RaceInProgressScreen = () => { +export const RaceInProgressScreen = () => { const positiveAudioRef = useRef(null); const negativeAudioRef = useRef(null); const successAudioRef = useRef(null); @@ -125,7 +124,13 @@ const RaceInProgressScreen = () => { if (allCompleted && !completeRaceCalledRef.current) { completeRaceCalledRef.current = true; successAudioRef.current.play(); - completeRace({ dispatch, currentFinishedElapsedTime }); + + dispatch({ + type: COMPLETE_RACE, + payload: { + elapsedTime: currentFinishedElapsedTime, + }, + }); } }, [checkpoints, isFinishComplete, currentFinishedElapsedTime, dispatch]); @@ -175,24 +180,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..5607eb0 --- /dev/null +++ b/client/src/components/SwitchRace/SwitchTrackScreen.jsx @@ -0,0 +1,94 @@ +import { useState, useContext, useEffect } from "react"; + +// components +import { BackButton, Footer } from "@components"; + +// context +import { GlobalDispatchContext, GlobalStateContext } from "@context/GlobalContext"; +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, trackLastSwitchedDate } = useContext(GlobalStateContext); + + const [selectedTrack, setSelectedTrack] = useState(null); + 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", { selectedTrack }) + .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) }, + }); + setAreAllButtonsDisabled(false); + }); + }; + + return ( + <> + dispatch({ type: SCREEN_MANAGER.SHOW_HOME_SCREEN })} /> + +
+

Choose New Track

+

Updates will reflect for everyone

+
+ +
+ {tracks?.map((track) => { + return ( + + ); + })} +
+ +
+ +
+ + ); +}; + +export default SwitchTrackScreen; diff --git a/client/src/components/index.js b/client/src/components/index.js new file mode 100644 index 0000000..014642f --- /dev/null +++ b/client/src/components/index.js @@ -0,0 +1,20 @@ +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/NewBadgeModal.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..f402e30 100644 --- a/client/src/context/reducer.js +++ b/client/src/context/reducer.js @@ -6,6 +6,9 @@ import { CANCEL_RACE, LOAD_GAME_STATE, RESET_GAME, + SET_VISITOR_INVENTORY, + SET_SCENE_DATA, + SET_LEADERBOARD, SET_ERROR, } from "./types"; @@ -23,6 +26,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 +98,30 @@ const globalReducer = (state, action) => { numberOfCheckpoints: payload.numberOfCheckpoints, startTimestamp: payload.startTimestamp, 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_LEADERBOARD: + return { + ...state, + leaderboard: payload.leaderboard, + highScore: payload.highScore, error: "", }; case SET_ERROR: diff --git a/client/src/context/types.js b/client/src/context/types.js index d41b7bf..2a814f5 100644 --- a/client/src/context/types.js +++ b/client/src/context/types.js @@ -5,8 +5,14 @@ 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 SET_LEADERBOARD = "SET_LEADERBOARD"; 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..1b59b5d 100644 --- a/client/src/index.scss +++ b/client/src/index.scss @@ -1,39 +1,121 @@ +.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; +} + +.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; + 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; +} + +.selected { + border: 2px solid #fdb41d; } -.heartbeat { - font-size: 40px; - color: #e74c3c; - animation: heartbeat 1.5s infinite; +table { + tr:nth-child(even) { + background-color: #f8f8f8; + } +} + +.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..6740084 --- /dev/null +++ b/client/src/pages/LeaderboardPage.jsx @@ -0,0 +1,48 @@ +import { useContext, useState, useEffect } from "react"; +import { Link, useLocation } from "react-router-dom"; + +// components +import { PageContainer, Leaderboard, Footer } 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); + + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + + 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/completeRace.js b/client/src/utils/completeRace.js deleted file mode 100644 index 0b1961b..0000000 --- a/client/src/utils/completeRace.js +++ /dev/null @@ -1,21 +0,0 @@ -import { backendAPI, getErrorMessage } from "@utils"; -import { SET_ERROR, COMPLETE_RACE } from "@context/types"; - -export const completeRace = async ({ dispatch, currentFinishedElapsedTime }) => { - try { - const result = await backendAPI.post("/race/complete-race"); - if (result.status === 200) { - dispatch({ - type: COMPLETE_RACE, - payload: { - elapsedTime: currentFinishedElapsedTime, - }, - }); - } - } 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/client/src/utils/loadGameState.js b/client/src/utils/loadGameState.js index 09fa205..f63df0c 100644 --- a/client/src/utils/loadGameState.js +++ b/client/src/utils/loadGameState.js @@ -5,33 +5,51 @@ 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, + trackLastSwitchedDate, + } = 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, + trackLastSwitchedDate, }, }); - 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/package-lock.json b/package-lock.json index c0fedb9..80657a2 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.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.17.4", - "resolved": "https://registry.npmjs.org/@rtsdk/topia/-/topia-0.17.4.tgz", - "integrity": "sha512-/RTrB+FtsyktBPZ9OyBs3lWZWoLbP7Bt1YQFuxJwsKU5JtDWw1EOUo3qIpTdBpegd09KjQS0fAaXEbdOFBqKCg==" + "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 f128bb7..580977d 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.19.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..af9bcd5 100644 --- a/server/controllers/handleCheckpointEntered.js +++ b/server/controllers/handleCheckpointEntered.js @@ -26,14 +26,23 @@ 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]) { - 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({ @@ -48,25 +57,62 @@ export const handleCheckpointEntered = async (req, res) => { message: "Error firing toast", }), ); + + 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(cachedCheckpoints)); + 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, - }); - const result = await finishLineEntered({ credentials, 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 { const result = await checkpointEntered({ diff --git a/server/controllers/handleCompleteRace.js b/server/controllers/handleCompleteRace.js deleted file mode 100644 index 644f750..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 } = await getVisitor(credentials); - const elapsedTime = visitorProgress.elapsedTime; - - return res.json({ success: true, elapsedTime }); - } catch (error) { - return errorHandler({ - error, - functionName: "handleCompleteRace", - message: "Error completing race", - req, - res, - }); - } -}; 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/handleGetLeaderboard.js b/server/controllers/handleGetLeaderboard.js new file mode 100644 index 0000000..f6b31f7 --- /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 { profileId, urlSlug, sceneDropId } = credentials; + + const world = await World.create(urlSlug, { credentials }); + await world.fetchDataObject(); + const sceneData = world.dataObject?.[sceneDropId]; + + const { leaderboardArray, highScore } = await formatLeaderboard(sceneData.leaderboard, profileId); + + return res.json({ success: true, leaderboard: leaderboardArray, highScore }); + } catch (error) { + return errorHandler({ + error, + functionName: "handleGetLeaderboard", + message: "Error fetching leaderboard", + req, + res, + }); + } +}; 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 1c8592b..417dc83 100644 --- a/server/controllers/handleLoadGameState.js +++ b/server/controllers/handleLoadGameState.js @@ -1,10 +1,17 @@ -import { World, errorHandler, getCredentials, 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) => { 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 }); @@ -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]; @@ -57,39 +65,26 @@ export const handleLoadGameState = async (req, res) => { ); } - const { visitor, visitorProgress } = await getVisitor(credentials, true); - const { checkpoints, highScore, startTimestamp } = visitorProgress; + const { leaderboardArray, highScore } = await formatLeaderboard(sceneData.leaderboard, profileId); - const leaderboard = []; - for (const profileId in sceneData.leaderboard) { - const data = sceneData.leaderboard[profileId]; + const { visitor, visitorProgress, visitorInventory } = await getVisitor(credentials, true); + let { checkpoints, startTimestamp } = visitorProgress; - 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 { badges } = await getInventoryItems(credentials); return res.json({ checkpointsCompleted: checkpoints, elapsedTimeInSeconds: startTimestamp ? Math.floor((now - startTimestamp) / 1000) : 0, highScore, isAdmin: visitor.isAdmin, - leaderboard, + leaderboard: leaderboardArray, numberOfCheckpoints: sceneData.numberOfCheckpoints, startTimestamp, success: true, tracks: parseEnvJson(process.env.TRACKS) || TRACKS, + visitorInventory, + badges, + trackLastSwitchedDate: sceneData.trackLastSwitchedDate || null, }); } catch (error) { return errorHandler({ diff --git a/server/controllers/handleRaceStart.js b/server/controllers/handleRaceStart.js index 0b385e3..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({ 0: 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/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/controllers/handleSwitchTrack.js b/server/controllers/handleSwitchTrack.js index 6ad33a9..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, }); @@ -64,11 +65,17 @@ export const handleSwitchTrack = async (req, res) => { isPartial: true, }); + const sceneData = { + trackName: name, + 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 +90,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..75b2359 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 "./handleCheckpointEntered.js"; +export * from "./handleRaceStart.js"; export * from "./handleGetEvents.js"; -export * from "./handleCompleteRace.js"; +export * from "./handleGetLeaderboard.js"; +export * from "./handleGetVisitorInventory.js"; +export * from "./handleLoadGameState.js"; export * from "./handleResetGame.js"; export * from "./handleSwitchTrack.js"; diff --git a/server/redis/redis.js b/server/redis/redis.js index 74c68c9..03431d8 100644 --- a/server/redis/redis.js +++ b/server/redis/redis.js @@ -1,35 +1,179 @@ -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"; + } + + 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://"); + + console.log(`[Redis] Connection details - Host: ${host}, Port: ${port}, TLS: ${tls}, Username: ${username}`); + + 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, + 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"); + return redis.createCluster({ + useReplicas: true, + rootNodes: [ + { + url: safeUrl, + socket: { + tls: tls + ? { + servername: host, + checkServerIdentity: () => undefined, + } + : false, + connectTimeout: 10000, + }, + }, + ], + 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); - 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; - if ( this.connections.some( ({ res: existingConnection }) => @@ -37,7 +181,6 @@ const redisObj = { existingConnection.req.query.profileId === profileId, ) ) { - // Replace old connection with new one this.connections.splice( this.connections.findIndex( ({ res: existingConnection }) => @@ -50,12 +193,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 +215,75 @@ 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.publisher.on("end", () => console.log("[Redis] Publisher connection ended")); +gameManager.publisher.on("ready", () => console.log("[Redis] Publisher is ready")); -redisObj.subscribe(`${process.env.INTERACTIVE_KEY}_RACE`); +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() { + 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..."); + 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..."); + 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 + 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); + 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"); + } +} -redisObj.publisher.on("error", (err) => console.error("Publisher Error", err)); -redisObj.subscriber.on("error", (err) => console.error("Subscriber Error", err)); +// Kick off initialization (top-level) +initRedis(); +// 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; diff --git a/server/routes.js b/server/routes.js index f8caef1..ea68b5c 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1,12 +1,13 @@ import express from "express"; import { - handleRaceStart, - handleCheckpointEntered, - handleLoadGameState, handleCancelRace, + handleCheckpointEntered, handleGetEvents, - handleCompleteRace, + handleGetLeaderboard, + handleGetVisitorInventory, + handleLoadGameState, + handleRaceStart, handleResetGame, handleSwitchTrack, } from "./controllers/index.js"; @@ -37,12 +38,14 @@ 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 new file mode 100644 index 0000000..d36c9c4 --- /dev/null +++ b/server/utils/badges/awardBadge.js @@ -0,0 +1,39 @@ +import { Ecosystem } from "../topiaInit.js"; + +export const awardBadge = async ({ credentials, visitor, visitorInventory, badgeName, redisObj, profileId }) => { + try { + if (visitorInventory[badgeName]) return { success: true }; + + const ecosystem = await Ecosystem.create({ credentials }); + await ecosystem.fetchInventoryItems(); + + const inventoryItem = ecosystem.inventoryItems?.find((item) => item.name === badgeName); + if (!inventoryItem) throw new Error(`Inventory item ${badgeName} not found in ecosystem`); + + await visitor.grantInventoryItem(inventoryItem, 1); + + await visitor + .fireToast({ + title: "Badge Awarded", + text: `You have earned the ${badgeName} badge!`, + }) + .catch(() => console.error(`Failed to fire toast after awarding the ${badgeName} badge.`)); + + 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) { + return new Error(error); + } +}; diff --git a/server/utils/badges/getInventoryItems.js b/server/utils/badges/getInventoryItems.js new file mode 100644 index 0000000..55777a7 --- /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.name] = { + 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.name] = badge; + }); + + return { + badges: sortedBadges, + }; + } catch (error) { + return standardizeError(error); + } +}; 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/index.js b/server/utils/badges/index.js new file mode 100644 index 0000000..cbe1195 --- /dev/null +++ b/server/utils/badges/index.js @@ -0,0 +1,4 @@ +export * from "./awardBadge.js"; +export * from "./getInventoryItems.js"; +export * from "./getTimeInSeconds.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..f1f27ae 100644 --- a/server/utils/checkpoints/finishLineEntered.js +++ b/server/utils/checkpoints/finishLineEntered.js @@ -1,15 +1,25 @@ import { WorldActivityType } from "@rtsdk/topia"; -import { World, errorHandler, getVisitor, timeToValue, updateVisitorProgress } from "../index.js"; +import { + World, + errorHandler, + getVisitor, + awardBadge, + isNewHighScoreTop3, + timeToValue, + updateVisitorProgress, +} from "../index.js"; -export const finishLineEntered = async ({ credentials, currentElapsedTime }) => { +export const finishLineEntered = async ({ credentials, currentElapsedTime, wasWrongCheckpointEntered, redisObj }) => { try { const { assetId, displayName, profileId, sceneDropId, urlSlug } = credentials; + const promises = []; + const world = World.create(urlSlug, { credentials }); 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; @@ -19,12 +29,14 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime }) => !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", + }), + ), ); } @@ -43,35 +55,158 @@ export const finishLineEntered = async ({ credentials, currentElapsedTime }) => }, visitor, visitorProgress, + hasCompletedRace: true, }); 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", - }), + 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) { + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Race Rookie", redisObj, profileId }).catch( + (error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Race Rookie badge", + }), + ), ); + } + + // Award Top 3 Racer badge if newHighScore is in top 3 of leaderboard + const shouldGetTop3Badge = await isNewHighScoreTop3(raceObject.leaderboard, newHighScore); + if (shouldGetTop3Badge) { + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Top 3 Racer", redisObj, profileId }).catch( + (error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Top 3 Racer badge", + }), + ), + ); + } - visitor - .triggerParticle({ - name: "trophy_float", - duration: 3, - }) - .catch((error) => + // 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) { + promises.push( + 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) { + promises.push( + 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) { + promises.push( + 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) { + promises.push( + 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) { + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Never Give Up", redisObj, profileId }).catch( + (error) => + errorHandler({ + error, + functionName: "finishLineEntered", + message: "Error awarding Never Give Up badge", + }), + ), + ); + } + + // 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 triggering particle effects", + message: `Error awarding ${raceObject.trackName} completion badge`, }), - ); + ), + ); + + const results = await Promise.allSettled(promises); + results.forEach((result) => { + if (result.status === "rejected") console.error(result.reason); + }); return; } catch (error) { diff --git a/server/utils/formatLeaderboard.js b/server/utils/formatLeaderboard.js new file mode 100644 index 0000000..e2a0591 --- /dev/null +++ b/server/utils/formatLeaderboard.js @@ -0,0 +1,26 @@ +export const formatLeaderboard = async (leaderboard, profileId) => { + 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); + + let highScore; + if (Object.keys(leaderboard).includes(profileId)) highScore = leaderboard[profileId].split("|")[1]; + + return { leaderboardArray, highScore }; +}; diff --git a/server/utils/index.js b/server/utils/index.js index ce851fa..74989a8 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -1,8 +1,10 @@ +export * from "./badges/index.js"; export * from "./checkpoints/index.js"; 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"; 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,