From b620e8bc28af8522e3ae8958ed8a71229b95ef54 Mon Sep 17 00:00:00 2001 From: jimenezz22 Date: Sun, 22 Jun 2025 19:22:17 -0600 Subject: [PATCH] feat: Implement mission completion system with validation and reward claiming - Added useMissionCompleter hook for handling mission completion logic. - Integrated mission validation utilities to check if missions can be completed based on game data. - Enhanced Map component to load and complete missions upon game results. - Updated DailyMissionsModal to support claiming rewards and refreshing mission data. - Improved mission display with visual indicators for completion and claiming status. - Added utility functions for mapping themes and golems to their respective IDs. --- client/dev-dist/sw.js | 2 +- .../screens/Game/CoinsRewardCalculator.ts | 18 +- .../components/screens/Game/GameOverModal.tsx | 198 +++++++++-- client/src/components/screens/Game/Map.tsx | 143 ++++++-- .../screens/Home/DailyMissionsModal.tsx | 314 ++++++++++++------ client/src/dojo/hooks/useMissionCompleter.tsx | 237 +++++++++++++ client/src/utils/missionValidation.ts | 264 +++++++++++++++ 7 files changed, 1002 insertions(+), 174 deletions(-) create mode 100644 client/src/dojo/hooks/useMissionCompleter.tsx create mode 100644 client/src/utils/missionValidation.ts diff --git a/client/dev-dist/sw.js b/client/dev-dist/sw.js index eb2981b..0243a08 100644 --- a/client/dev-dist/sw.js +++ b/client/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-86c9b217'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.g8ftgsh6a18" + "revision": "0.vs5n5ncup7" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/client/src/components/screens/Game/CoinsRewardCalculator.ts b/client/src/components/screens/Game/CoinsRewardCalculator.ts index 542f0c1..eaa7f5f 100644 --- a/client/src/components/screens/Game/CoinsRewardCalculator.ts +++ b/client/src/components/screens/Game/CoinsRewardCalculator.ts @@ -12,12 +12,20 @@ export interface ScoreRange { } // Predefined score ranges for coin rewards + // const SCORE_RANGES: ScoreRange[] = [ + // { min: 0, max: 999, coins: 50, label: "Beginner" }, + // { min: 1000, max: 2999, coins: 100, label: "Runner" }, + // { min: 3000, max: 5999, coins: 250, label: "Speedster" }, + // { min: 6000, max: 9999, coins: 500, label: "Champion" }, + // { min: 10000, max: Infinity, coins: 1000, label: "Legend" } + // ]; + const SCORE_RANGES: ScoreRange[] = [ - { min: 0, max: 999, coins: 50, label: "Beginner" }, - { min: 1000, max: 2999, coins: 100, label: "Runner" }, - { min: 3000, max: 5999, coins: 250, label: "Speedster" }, - { min: 6000, max: 9999, coins: 500, label: "Champion" }, - { min: 10000, max: Infinity, coins: 1000, label: "Legend" } + { min: 0, max: 250, coins: 100, label: "Beginner" }, + { min: 251, max: 500, coins: 250, label: "Runner" }, + { min: 501, max: 1000, coins: 400, label: "Speedster" }, + { min: 1001, max: 2000, coins: 500, label: "Champion" }, + { min: 2000, max: Infinity, coins: 1000, label: "Legend" } ]; /** diff --git a/client/src/components/screens/Game/GameOverModal.tsx b/client/src/components/screens/Game/GameOverModal.tsx index 0b47d84..278c890 100644 --- a/client/src/components/screens/Game/GameOverModal.tsx +++ b/client/src/components/screens/Game/GameOverModal.tsx @@ -15,6 +15,16 @@ interface GameOverModalProps { isProcessingReward?: boolean; rewardError?: string | null; rewardTxStatus?: 'PENDING' | 'SUCCESS' | 'REJECTED' | null; + + // Props for mission feedback + isMissionCompleting?: boolean; + missionError?: string | null; + completedMissions?: Array<{ + mission: { id: number; description: string }; + reason: string; + }>; + worldId?: number; + golemId?: number; } const GameOverModal: React.FC = ({ @@ -26,55 +36,50 @@ const GameOverModal: React.FC = ({ isProcessingReward = false, rewardError = null, rewardTxStatus = null, + isMissionCompleting = false, + missionError = null, + completedMissions = [], }) => { const isNewRecord = score > record; - // Add usePlayer hook to refresh data const { refetch: refetchPlayer } = usePlayer(); - - // Calculate coin reward based on score const coinReward = useCoinReward(score); - console.log('Coin Reward:', coinReward); - // Function to handle Restart click + // Calculated variables + const isProcessing = isProcessingReward || isMissionCompleting; + const hasErrors = rewardError || missionError; + const hasCompletedMissions = completedMissions.length > 0; + + + const handleRestartClick = async () => { audioManager.playClickSound(); - // Only fetch if the transaction was successful if (rewardTxStatus === 'SUCCESS') { try { - console.log('Refreshing player data before restart...'); await refetchPlayer(); - console.log('Player data refreshed successfully'); } catch (err) { - console.error('Error refreshing player data:', err); + // Silently handle refresh error } } - // Call the restart handler from the parent component onRestart(); }; - // Function to handle Exit click const handleExitClick = async () => { audioManager.playClickSound(); - // Only fetch if the transaction was successful if (rewardTxStatus === 'SUCCESS') { try { - console.log('Refreshing player data before exit...'); await refetchPlayer(); - console.log('Player data refreshed successfully'); } catch (err) { - console.error('Error refreshing player data:', err); + // Silently handle refresh error } } - // Call the exit handler from the parent component onExit(); }; - // Determine the transaction status message const getTransactionStatusMessage = () => { if (rewardError) { return `Error: ${rewardError}`; @@ -96,7 +101,18 @@ const GameOverModal: React.FC = ({ return null; }; - // Determine the color of the transaction status + const getMissionStatusMessage = () => { + if (missionError) { + return `Mission Error: ${missionError}`; + } + + if (isMissionCompleting) { + return 'Checking mission completion...'; + } + + return null; + }; + const getStatusColor = () => { if (rewardError) return 'bg-red-500 text-white'; if (rewardTxStatus === 'SUCCESS') return 'bg-green-500 text-white'; @@ -104,8 +120,14 @@ const GameOverModal: React.FC = ({ return 'bg-blue-500 text-white'; }; - const transactionStatusMessage = getTransactionStatusMessage(); + const getMissionStatusColor = () => { + if (missionError) return 'bg-red-500 text-white'; + if (hasCompletedMissions) return 'bg-green-500 text-white'; + return 'bg-blue-500 text-white'; + }; + const transactionStatusMessage = getTransactionStatusMessage(); + const missionStatusMessage = getMissionStatusMessage(); return ( @@ -117,7 +139,7 @@ const GameOverModal: React.FC = ({ exit={{ opacity: 0 }} > = ({ {/* Tier Badge */} = ({
{coinReward.pointsToNextTier} points to {coinReward.nextTier?.label}
-
+
= ({ {/* New Record Banner */} {isNewRecord && ( = ({ )} - {/* Transaction Status Message */} + {/* Processing Status Messages */} {transactionStatusMessage && ( = ({ {transactionStatusMessage} )} + + {/* Mission Status */} + {missionStatusMessage && ( + + {missionStatusMessage} + + )} + + {/* Mission Completion Success */} + {hasCompletedMissions && ( + +
+ + πŸŽ‰ {completedMissions.length} Mission{completedMissions.length > 1 ? 's' : ''} Completed! + +
+ +
+ {completedMissions.map((cm, index) => ( + +
+

+ {cm.reason} +

+ βœ“ +
+
+ ))} +
+ + +

+ πŸ’° Check Daily Missions to claim rewards! +

+
+
+ )} + + {/* Error Messages */} + {hasErrors && !isProcessing && ( + +

+ ⚠️ Processing Issues +

+ {rewardError && ( +

+ Reward Error: {rewardError} +

+ )} + {missionError && ( +

+ Mission Error: {missionError} +

+ )} +
+ )}
{/* Buttons */}
- EXIT + {isProcessing ? ( +
+ + WAIT +
+ ) : ( + 'EXIT' + )}
- RESTART + {isProcessing ? ( +
+ + WAIT +
+ ) : ( + 'RESTART' + )}
diff --git a/client/src/components/screens/Game/Map.tsx b/client/src/components/screens/Game/Map.tsx index e66fa1e..90ce30a 100644 --- a/client/src/components/screens/Game/Map.tsx +++ b/client/src/components/screens/Game/Map.tsx @@ -1,10 +1,17 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; import GameCanvas from './GameCanvas'; import GameOverModal from './GameOverModal'; import { useGameRewards } from '../../../dojo/hooks/useGameRewards'; import { useCoinReward } from './CoinsRewardCalculator'; import type { GameThemeAssets, GamePhysics, GameDifficultyConfig, MapTheme, ObstacleConfig } from '../../types/game'; +import { useMissionCompleter } from '../../../dojo/hooks/useMissionCompleter'; +import { themeToWorldId } from '../../../utils/missionValidation'; +import useAppStore from '../../../zustand/store'; +import { useMissionQuery } from '../../../dojo/hooks/useMissionQuery'; +import { useAccount } from "@starknet-react/core"; +import { addAddressPadding } from "starknet"; + import forestBG from '../../../assets/Maps/Forest/ForestMap.webp'; import iceBG from '../../../assets/Maps/Ice/IceMap.webp'; import volcanoBG from '../../../assets/Maps/Volcano/VolcanoMap.webp'; @@ -144,12 +151,12 @@ const THEME_MAP_CONFIGS: Record void; + selectedGolemId?: number; } const MapComponent: React.FC = ({ @@ -157,6 +164,7 @@ const MapComponent: React.FC = ({ selectedPlayerRunFrames, selectedPlayerJumpFrames, onExitGame, + selectedGolemId = 1, }) => { if (!theme || !THEME_MAP_CONFIGS[theme]) { return
Invalid theme: {String(theme)}
; @@ -164,6 +172,15 @@ const MapComponent: React.FC = ({ const themeConfig = THEME_MAP_CONFIGS[theme]; + // Hooks for mission loading + const { account } = useAccount(); + const { fetchTodayMissions } = useMissionQuery(); + + const playerAddress = useMemo(() => + account ? addAddressPadding(account.address) : null, + [account] + ); + const [currentScore, setCurrentScore] = useState(0); const [highScore, setHighScore] = useState(() => parseInt(localStorage.getItem(`golemRunner_${theme}_highscore`) || '0', 10) @@ -175,7 +192,7 @@ const MapComponent: React.FC = ({ height: window.innerHeight, }); - // Get game rewards from hook + // Game rewards hook const { submitGameResults, isProcessing: isProcessingReward, @@ -183,21 +200,58 @@ const MapComponent: React.FC = ({ txStatus: rewardTxStatus } = useGameRewards(); - //State to track if the reward has been submitted const [rewardSubmitted, setRewardSubmitted] = useState(false); - // Get coin reward based on score from the hook + // Mission completion hooks and state + const { + isCompleting: isMissionCompleting, + error: missionError, + completedMissions, + checkAndCompleteMissions, + clearCompletedMissions + } = useMissionCompleter(); + + const [missionCompletionSubmitted, setMissionCompletionSubmitted] = useState(false); + const [missionsLoaded, setMissionsLoaded] = useState(false); + + // Store access + const { currentGolem, missions: storeMissions, setMissions } = useAppStore(); + const coinReward = useCoinReward(currentScore); + const worldId = useMemo(() => themeToWorldId(theme), [theme]); - // Convert theme to worldId - const worldId = useMemo(() => { - switch(theme) { - case 'forest': return 1; - case 'ice': return 2; - case 'volcano': return 3; - default: return 1; + const currentGolemId = useMemo(() => { + return currentGolem?.id || selectedGolemId || 1; + }, [currentGolem, selectedGolemId]); + + // Load today's missions for mission completion validation + const loadTodayMissions = useCallback(async () => { + if (!playerAddress || missionsLoaded) return; + + try { + const todayMissions = await fetchTodayMissions(playerAddress); + + if (todayMissions.length > 0) { + setMissions(todayMissions); + setMissionsLoaded(true); + } + } catch (error) { + // Silently handle error - missions will be empty + // User can still play, just won't complete missions } - }, [theme]); + }, [playerAddress, missionsLoaded, fetchTodayMissions, setMissions]); + + // Load missions when player connects + useEffect(() => { + if (playerAddress) { + loadTodayMissions(); + } + }, [playerAddress, loadTodayMissions]); + + // Reset mission state when player changes + useEffect(() => { + setMissionsLoaded(false); + }, [playerAddress]); useEffect(() => { const updateDimensions = () => @@ -219,21 +273,53 @@ const MapComponent: React.FC = ({ setHighScore(parseInt(localStorage.getItem(`golemRunner_${theme}_highscore`) || '0', 10)); }, [theme]); - // Send rewards when modal is shown + // Handle mission completion after successful game rewards + const handleMissionCompletion = useCallback(async ( + coinsCollected: number, + worldId: number, + golemId: number + ) => { + if (missionCompletionSubmitted) return; + + // If no missions in store, try to load them + if (storeMissions.length === 0) { + try { + await loadTodayMissions(); + // Wait for store to update + await new Promise(resolve => setTimeout(resolve, 500)); + } catch (error) { + return; + } + } + + try { + const gameData = { + coinsCollected, + worldId, + golemId + }; + + await checkAndCompleteMissions(gameData); + setMissionCompletionSubmitted(true); + + } catch (error) { + // Silently handle mission completion errors + // Game rewards are already processed successfully + } + }, [missionCompletionSubmitted, checkAndCompleteMissions, storeMissions.length, loadTodayMissions]); + + // Process game rewards and trigger mission completion useEffect(() => { if (showGameOverModal && !rewardSubmitted && currentScore > 0) { - console.log(`Submitting game results: score=${currentScore}, coins=${coinReward.coins}, worldId=${worldId}`); submitGameResults(currentScore, coinReward.coins, worldId) .then(result => { if (result.success) { - console.log("Game rewards processed successfully"); setRewardSubmitted(true); - } else { - console.error("Failed to process game rewards:", result.error); + handleMissionCompletion(coinReward.coins, worldId, currentGolemId); } }); } - }, [showGameOverModal, rewardSubmitted, currentScore, coinReward.coins, worldId, submitGameResults]); + }, [showGameOverModal, rewardSubmitted, currentScore, coinReward.coins, worldId, submitGameResults, currentGolemId, handleMissionCompletion]); const finalAssetsForGameCanvas: GameThemeAssets = useMemo(() => { const runFrames = selectedPlayerRunFrames.length > 0 ? selectedPlayerRunFrames : []; @@ -261,16 +347,24 @@ const MapComponent: React.FC = ({ localStorage.setItem(`golemRunner_${theme}_highscore`, finalScore.toString()); } setShowGameOverModal(true); - setRewardSubmitted(false); // Reset game state + setRewardSubmitted(false); + setMissionCompletionSubmitted(false); }; - const handleRestartGame = () => { + const handleCloseModal = useCallback(() => { setShowGameOverModal(false); + setRewardSubmitted(false); + setMissionCompletionSubmitted(false); + clearCompletedMissions(); + }, [clearCompletedMissions]); + + const handleRestartGame = () => { + handleCloseModal(); setGameKey(Date.now()); }; const handleExitAndCloseModal = () => { - setShowGameOverModal(false); + handleCloseModal(); onExitGame(); }; @@ -297,6 +391,11 @@ const MapComponent: React.FC = ({ isProcessingReward={isProcessingReward} rewardError={rewardError} rewardTxStatus={rewardTxStatus} + isMissionCompleting={isMissionCompleting} + missionError={missionError} + completedMissions={completedMissions} + worldId={worldId} + golemId={currentGolemId} /> )}
diff --git a/client/src/components/screens/Home/DailyMissionsModal.tsx b/client/src/components/screens/Home/DailyMissionsModal.tsx index 567f291..226b42a 100644 --- a/client/src/components/screens/Home/DailyMissionsModal.tsx +++ b/client/src/components/screens/Home/DailyMissionsModal.tsx @@ -47,21 +47,29 @@ const getEnumVariant = (enumObj: any, defaultValue: string): string => { return defaultValue; }; +/** + * NEW FUNCTION: Determines if a mission is completed based on its status + */ +const isMissionCompleted = (mission: Mission): boolean => { + const statusVariant = getEnumVariant(mission.status, 'Pending'); + return statusVariant === 'Completed'; +}; + /** * Converts Mission bindings to display data for UI */ -const missionToDisplayData = (mission: Mission): MissionDisplayData => { +const missionToDisplayData = (mission: Mission, claimedMissionIds: Set): MissionDisplayData => { let difficulty: 'Easy' | 'Mid' | 'Hard' = 'Easy'; if (mission.target_coins >= 1000) difficulty = 'Hard'; else if (mission.target_coins >= 500) difficulty = 'Mid'; const worldVariant = getEnumVariant(mission.required_world, 'Forest'); const golemVariant = getEnumVariant(mission.required_golem, 'Fire'); - const statusVariant = getEnumVariant(mission.status, 'Pending'); + const completed = isMissionCompleted(mission); + const claimed = claimedMissionIds.has(mission.id.toString()); const requiredWorld = worldVariant.charAt(0).toUpperCase() + worldVariant.slice(1); const requiredGolem = golemVariant.charAt(0).toUpperCase() + golemVariant.slice(1); - const completed = statusVariant === 'Completed'; const title = mission.description.split(' ').slice(0, 3).join(' ') || 'Daily Mission'; const displayData: MissionDisplayData = { @@ -73,7 +81,7 @@ const missionToDisplayData = (mission: Mission): MissionDisplayData => { requiredWorld, requiredGolem, completed, - claimed: false + claimed }; return displayData; @@ -98,12 +106,67 @@ export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) { const [claimedMission, setClaimedMission] = useState(null); const [claimedMissionIds, setClaimedMissionIds] = useState>(new Set()); + // NEW STATE: For claim reward process + const [claimingMissionId, setClaimingMissionId] = useState(null); + const [claimError, setClaimError] = useState(null); + // Procesar data const { todayMissions, hasData } = useMissionData(missions); // Estados combinados const isLoading = isQuerying || isSpawning; - const error = queryError || spawnError; + const error = queryError || spawnError || claimError; + + // NEW FUNCTION: Refresh missions after claim + const refreshMissionsAfterClaim = useCallback(async () => { + if (!playerAddress) return; + + try { + console.log("πŸ”„ Refreshing missions after claim..."); + const refreshedMissions = await fetchTodayMissions(playerAddress); + setMissions(refreshedMissions); + console.log("βœ… Missions refreshed successfully"); + } catch (error) { + console.error("❌ Error refreshing missions:", error); + } + }, [playerAddress, fetchTodayMissions]); + + // NEW FUNCTION: Handle claim reward (placeholder for future implementation) + const handleClaimReward = useCallback(async (mission: MissionDisplayData) => { + console.log(`🎯 Claiming reward for mission ${mission.id}:`, mission); + + setClaimingMissionId(mission.id); + setClaimError(null); + + try { + // TODO: Implement actual claim reward transaction + // This would call a hook like useMissionRewardClaimer + // For now, we'll simulate the process + + console.log("πŸ”„ Processing claim reward transaction..."); + + // Simulate API call delay + await new Promise(resolve => setTimeout(resolve, 2000)); + + // For now, just mark as claimed locally + // In the real implementation, this would be handled by the blockchain transaction + setClaimedMission(mission); + setShowCelebration(true); + setClaimedMissionIds(prev => new Set(prev).add(mission.id)); + + // Refresh missions from blockchain after successful claim + await refreshMissionsAfterClaim(); + + console.log("βœ… Mission reward claimed successfully"); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Failed to claim reward"; + setClaimError(errorMessage); + console.error("❌ Error claiming mission reward:", error); + } finally { + setClaimingMissionId(null); + } + }, [refreshMissionsAfterClaim]); // 🎯 ORQUESTACIΓ“N PRINCIPAL const initializeMissions = useCallback(async () => { @@ -143,6 +206,8 @@ export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) { useEffect(() => { setMissions([]); setIsInitialized(false); + setClaimedMissionIds(new Set()); // NEW: Reset claimed missions + setClaimError(null); // NEW: Reset claim errors }, [playerAddress]); // Early return if no account @@ -179,13 +244,35 @@ export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) { ); } - // Convertir misiones + // Convertir misiones - UPDATED to pass claimedMissionIds const displayMissions: MissionDisplayData[] = todayMissions.map(mission => { - const displayData = missionToDisplayData(mission); - displayData.claimed = claimedMissionIds.has(mission.id.toString()); - return displayData; + return missionToDisplayData(mission, claimedMissionIds); }); + // NEW FUNCTION: Get mission card styling based on status + const getMissionCardStyling = (mission: MissionDisplayData) => { + if (mission.completed) { + if (mission.claimed) { + return { + borderColor: 'border-gray-300', + backgroundColor: 'bg-gray-50', + opacity: 'opacity-75' + }; + } else { + return { + borderColor: 'border-green-200', + backgroundColor: 'bg-green-50', + opacity: 'opacity-100' + }; + } + } + return { + borderColor: 'border-gray-200 hover:border-gray-300', + backgroundColor: 'bg-white', + opacity: 'opacity-100' + }; + }; + const getDifficultyStyle = (difficulty: MissionDisplayData['difficulty']) => { switch (difficulty) { case 'Easy': @@ -199,12 +286,6 @@ export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) { } }; - const handleClaimReward = (mission: MissionDisplayData) => { - setClaimedMission(mission); - setShowCelebration(true); - setClaimedMissionIds(prev => new Set(prev).add(mission.id)); - }; - const handleCloseCelebration = () => { setShowCelebration(false); setClaimedMission(null); @@ -291,108 +372,121 @@ export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) { {!showLoading && !error && hasData && (
- {displayMissions.map((mission: MissionDisplayData, index: number) => ( - -
-
- - {mission.difficulty} - + {displayMissions.map((mission: MissionDisplayData, index: number) => { + const styling = getMissionCardStyling(mission); + const isClaimingThis = claimingMissionId === mission.id; + + return ( + +
+
+ + {mission.difficulty} + + +
+ + {mission.reward} + + Coin Icon +
+
-
- - {mission.reward} - - Coin Icon + βœ“ +
+ )} +
+ +
+

+ {mission.title} +

+

+ {mission.description} +

+ +
+ πŸ—ΊοΈ {mission.requiredWorld} + 🧌 {mission.requiredGolem} Golem
- + + {/* NEW SECTION: Action Buttons */} + {mission.completed && !mission.claimed && ( +
+ handleClaimReward(mission)} + disabled={isClaimingThis || claimingMissionId !== null} + > + {isClaimingThis ? ( + <> + + Claiming... + + ) : ( + 'Claim Reward' + )} + +
+ )} + + {/* Status indicator overlay */} {mission.completed && ( -
- βœ“ -
+ ? 'bg-gray-500/10' + : 'bg-green-500/5' + }`} /> )} -
- -
-

- {mission.title} -

-

- {mission.description} -

- -
- πŸ—ΊοΈ {mission.requiredWorld} - 🧌 {mission.requiredGolem} Golem -
-
- - {mission.completed && !mission.claimed && ( -
- handleClaimReward(mission)} - > - Claim Reward - -
- )} - - {mission.completed && ( -
- )} - - ))} + + ); + })}
)} diff --git a/client/src/dojo/hooks/useMissionCompleter.tsx b/client/src/dojo/hooks/useMissionCompleter.tsx new file mode 100644 index 0000000..3b68b31 --- /dev/null +++ b/client/src/dojo/hooks/useMissionCompleter.tsx @@ -0,0 +1,237 @@ +import { useState, useCallback } from "react"; +import { useAccount } from "@starknet-react/core"; +import { useDojoSDK } from "@dojoengine/sdk/react"; +import { Account } from "starknet"; +import { + findCompletableMissions, + GameCompletionData, + CompletableMission +} from '../../utils/missionValidation'; +import { useMissionQuery } from './useMissionQuery'; +import useAppStore from '../../zustand/store'; + +interface UseMissionCompleterReturn { + isCompleting: boolean; + error: string | null; + completedMissions: CompletableMission[]; + checkAndCompleteMissions: (gameData: GameCompletionData) => Promise; + clearCompletedMissions: () => void; +} + +export const useMissionCompleter = (): UseMissionCompleterReturn => { + const { client } = useDojoSDK(); + const { account } = useAccount(); + const { fetchTodayMissions } = useMissionQuery(); + + // Zustand store actions + const { + missions: storeMissions, + setMissions, + setMissionsLoading, + setMissionsError + } = useAppStore(); + + // Local state for completion process + const [isCompleting, setIsCompleting] = useState(false); + const [error, setError] = useState(null); + const [completedMissions, setCompletedMissions] = useState([]); + + /** + * Executes blockchain transaction to mark mission as completed + */ + const executeMissionCompletion = useCallback(async ( + missionId: number + ): Promise<{ success: boolean; error?: string }> => { + if (!account) { + return { success: false, error: "No account connected" }; + } + + try { + console.log(`πŸ”„ Executing completion transaction for mission ${missionId}...`); + + const tx = await client.game.updateMission( + account as Account, + missionId + ); + + if (tx && tx.code === "SUCCESS") { + console.log(`βœ… Mission ${missionId} completion transaction successful:`, tx); + return { success: true }; + } else { + const errorMsg = `Transaction failed: ${tx?.code || 'Unknown error'}`; + console.error(`❌ Mission ${missionId} completion failed:`, errorMsg); + return { success: false, error: errorMsg }; + } + + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Unknown transaction error"; + console.error(`❌ Error completing mission ${missionId}:`, error); + return { success: false, error: errorMsg }; + } + }, [account, client.game]); + + /** + * Processes multiple mission completions sequentially + */ + const processMissionCompletions = useCallback(async ( + completableMissions: CompletableMission[] + ): Promise<{ successful: CompletableMission[]; failed: { mission: CompletableMission; error: string }[] }> => { + const successful: CompletableMission[] = []; + const failed: { mission: CompletableMission; error: string }[] = []; + + console.log(`πŸš€ Processing ${completableMissions.length} mission completions...`); + + for (let i = 0; i < completableMissions.length; i++) { + const completableMission = completableMissions[i]; + const missionId = completableMission.mission.id; + + try { + console.log(`πŸ“€ Processing mission ${i + 1}/${completableMissions.length}: ${missionId}`); + + const result = await executeMissionCompletion(missionId); + + if (result.success) { + successful.push(completableMission); + console.log(`βœ… Mission ${missionId} completed successfully`); + } else { + failed.push({ + mission: completableMission, + error: result.error || "Unknown error" + }); + console.error(`❌ Mission ${missionId} completion failed:`, result.error); + } + + // Small delay between transactions to avoid nonce conflicts + if (i < completableMissions.length - 1) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Unknown error"; + failed.push({ + mission: completableMission, + error: errorMsg + }); + console.error(`❌ Unexpected error processing mission ${missionId}:`, error); + } + } + + console.log(`🎯 Mission completion results: ${successful.length} successful, ${failed.length} failed`); + + return { successful, failed }; + }, [executeMissionCompletion]); + + /** + * Refetches missions from blockchain and updates store + */ + const refreshMissions = useCallback(async (playerAddress: string): Promise => { + try { + console.log("πŸ”„ Refreshing missions from blockchain..."); + + setMissionsLoading(true); + + // Wait a bit for Torii indexing after transactions + await new Promise(resolve => setTimeout(resolve, 3000)); + + const updatedMissions = await fetchTodayMissions(playerAddress); + + console.log(`βœ… Refreshed ${updatedMissions.length} missions from blockchain`); + + // Update Zustand store with fresh data + setMissions(updatedMissions); + setMissionsError(null); + + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Failed to refresh missions"; + console.error("❌ Error refreshing missions:", error); + setMissionsError(errorMsg); + } finally { + setMissionsLoading(false); + } + }, [fetchTodayMissions, setMissions, setMissionsLoading, setMissionsError]); + + /** + * Main function: Check and complete missions based on game completion data + */ + const checkAndCompleteMissions = useCallback(async ( + gameData: GameCompletionData + ): Promise => { + if (!account) { + setError("No account connected"); + return []; + } + + setIsCompleting(true); + setError(null); + setCompletedMissions([]); + + try { + console.log("🎯 Starting mission completion check:", gameData); + + // Step 1: Find completable missions from current store data + const completableMissions = findCompletableMissions(storeMissions, gameData); + + if (completableMissions.length === 0) { + console.log("ℹ️ No missions can be completed with current game data"); + return []; + } + + console.log(`πŸŽ‰ Found ${completableMissions.length} completable missions:`, + completableMissions.map(cm => ({ + id: cm.mission.id, + reason: cm.reason + })) + ); + + // Step 2: Process mission completion transactions + const { successful, failed } = await processMissionCompletions(completableMissions); + + // Step 3: Handle results + if (successful.length > 0) { + console.log(`βœ… Successfully completed ${successful.length} missions`); + setCompletedMissions(successful); + + // Step 4: Refresh missions from blockchain + await refreshMissions(account.address); + } + + if (failed.length > 0) { + const failedErrors = failed.map(f => `Mission ${f.mission.mission.id}: ${f.error}`); + const errorMessage = `Some missions failed to complete: ${failedErrors.join(', ')}`; + setError(errorMessage); + console.error("❌ Some mission completions failed:", failed); + } + + return successful; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Failed to complete missions"; + setError(errorMessage); + console.error("❌ Error in mission completion process:", error); + return []; + } finally { + setIsCompleting(false); + } + }, [ + account, + storeMissions, + processMissionCompletions, + refreshMissions + ]); + + /** + * Clears completed missions list (for UI reset) + */ + const clearCompletedMissions = useCallback(() => { + setCompletedMissions([]); + setError(null); + }, []); + + return { + isCompleting, + error, + completedMissions, + checkAndCompleteMissions, + clearCompletedMissions + }; +}; \ No newline at end of file diff --git a/client/src/utils/missionValidation.ts b/client/src/utils/missionValidation.ts new file mode 100644 index 0000000..cbab30f --- /dev/null +++ b/client/src/utils/missionValidation.ts @@ -0,0 +1,264 @@ +import { Mission } from '../dojo/bindings'; + +/** + * Mission validation utilities for determining completable missions + * Pure functions without side effects for easy testing + */ + +/** + * Maps theme strings to world IDs for mission validation + */ +export const WORLD_ID_MAP = { + 'forest': 1, + 'ice': 2, + 'volcano': 3 +} as const; + +/** + * Maps world IDs back to enum strings for validation + */ +export const WORLD_ENUM_MAP = { + 1: 'Forest', + 2: 'Glacier', // Ice world is actually Glacier in the enum + 3: 'Volcano' +} as const; + +/** + * Maps golem IDs to enum strings for validation + */ +export const GOLEM_ENUM_MAP = { + 1: 'Stone', // Starter golem + 2: 'Fire', + 3: 'Ice' +} as const; + +/** + * Game completion data from the Game Over modal + */ +export interface GameCompletionData { + coinsCollected: number; + worldId: number; + golemId: number; +} + +/** + * Result of mission validation + */ +export interface CompletableMission { + mission: Mission; + reason: string; // Why this mission is completable +} + +/** + * Safe function to extract enum variant value + */ +const getEnumVariant = (enumObj: any, defaultValue: string): string => { + if (!enumObj) return defaultValue; + + // Try activeVariant() function first + if (typeof enumObj.activeVariant === 'function') { + try { + return enumObj.activeVariant(); + } catch (error) { + console.warn("activeVariant failed:", error); + } + } + + // Try variant object structure + if (enumObj.variant && typeof enumObj.variant === 'object') { + const keys = Object.keys(enumObj.variant); + if (keys.length > 0) { + return keys[0]; + } + } + + // Try direct object keys + if (typeof enumObj === 'object') { + const keys = Object.keys(enumObj); + if (keys.length > 0) { + return keys[0]; + } + } + + return defaultValue; +}; + +/** + * Validates if the world requirement matches the played world + */ +export const validateWorldRequirement = ( + requiredWorld: any, + playedWorldId: number +): boolean => { + const requiredWorldVariant = getEnumVariant(requiredWorld, 'Forest'); + const expectedWorld = WORLD_ENUM_MAP[playedWorldId as keyof typeof WORLD_ENUM_MAP]; + + if (!expectedWorld) { + console.warn(`Unknown world ID: ${playedWorldId}`); + return false; + } + + const matches = requiredWorldVariant === expectedWorld; + + console.log(`πŸ—ΊοΈ World validation: Required=${requiredWorldVariant}, Played=${expectedWorld}, Matches=${matches}`); + + return matches; +}; + +/** + * Validates if the golem requirement matches the used golem + */ +export const validateGolemRequirement = ( + requiredGolem: any, + usedGolemId: number +): boolean => { + const requiredGolemVariant = getEnumVariant(requiredGolem, 'Stone'); + const expectedGolem = GOLEM_ENUM_MAP[usedGolemId as keyof typeof GOLEM_ENUM_MAP]; + + if (!expectedGolem) { + console.warn(`Unknown golem ID: ${usedGolemId}`); + return false; + } + + const matches = requiredGolemVariant === expectedGolem; + + console.log(`🧌 Golem validation: Required=${requiredGolemVariant}, Used=${expectedGolem}, Matches=${matches}`); + + return matches; +}; + +/** + * Validates if the coins requirement is met + */ +export const validateCoinsRequirement = ( + targetCoins: number, + coinsCollected: number +): boolean => { + const meets = coinsCollected >= targetCoins; + + console.log(`πŸ’° Coins validation: Required=${targetCoins}, Collected=${coinsCollected}, Meets=${meets}`); + + return meets; +}; + +/** + * Checks if a mission is currently pending (not completed) + */ +export const isMissionPending = (mission: Mission): boolean => { + const statusVariant = getEnumVariant(mission.status, 'Pending'); + const isPending = statusVariant === 'Pending'; + + console.log(`πŸ“‹ Mission ${mission.id} status: ${statusVariant}, isPending=${isPending}`); + + return isPending; +}; + +/** + * Validates if a single mission can be completed with the given game data + */ +export const validateMissionCompletion = ( + mission: Mission, + gameData: GameCompletionData +): { canComplete: boolean; reason: string } => { + console.log(`πŸ” Validating mission ${mission.id}:`, { + targetCoins: mission.target_coins, + description: mission.description + }); + + // Check if mission is still pending + if (!isMissionPending(mission)) { + return { + canComplete: false, + reason: `Mission ${mission.id} is not in pending status` + }; + } + + // Validate coins requirement + if (!validateCoinsRequirement(mission.target_coins, gameData.coinsCollected)) { + return { + canComplete: false, + reason: `Insufficient coins: need ${mission.target_coins}, collected ${gameData.coinsCollected}` + }; + } + + // Validate world requirement + if (!validateWorldRequirement(mission.required_world, gameData.worldId)) { + const requiredWorldVariant = getEnumVariant(mission.required_world, 'Forest'); + const playedWorld = WORLD_ENUM_MAP[gameData.worldId as keyof typeof WORLD_ENUM_MAP]; + return { + canComplete: false, + reason: `Wrong world: need ${requiredWorldVariant}, played ${playedWorld}` + }; + } + + // Validate golem requirement + if (!validateGolemRequirement(mission.required_golem, gameData.golemId)) { + const requiredGolemVariant = getEnumVariant(mission.required_golem, 'Stone'); + const usedGolem = GOLEM_ENUM_MAP[gameData.golemId as keyof typeof GOLEM_ENUM_MAP]; + return { + canComplete: false, + reason: `Wrong golem: need ${requiredGolemVariant}, used ${usedGolem}` + }; + } + + // All validations passed! + const reason = `Completed with ${gameData.coinsCollected} coins using ${GOLEM_ENUM_MAP[gameData.golemId as keyof typeof GOLEM_ENUM_MAP]} golem in ${WORLD_ENUM_MAP[gameData.worldId as keyof typeof WORLD_ENUM_MAP]}`; + + return { + canComplete: true, + reason + }; +}; + +/** + * Finds all missions that can be completed with the given game completion data + */ +export const findCompletableMissions = ( + missions: Mission[], + gameData: GameCompletionData +): CompletableMission[] => { + console.log(`🎯 Checking ${missions.length} missions for completion:`, gameData); + + const completableMissions: CompletableMission[] = []; + + for (const mission of missions) { + const validation = validateMissionCompletion(mission, gameData); + + if (validation.canComplete) { + completableMissions.push({ + mission, + reason: validation.reason + }); + + console.log(`βœ… Mission ${mission.id} can be completed: ${validation.reason}`); + } else { + console.log(`❌ Mission ${mission.id} cannot be completed: ${validation.reason}`); + } + } + + console.log(`πŸŽ‰ Found ${completableMissions.length} completable missions`); + + return completableMissions; +}; + +/** + * Utility function to convert theme string to world ID + */ +export const themeToWorldId = (theme: string): number => { + const worldId = WORLD_ID_MAP[theme as keyof typeof WORLD_ID_MAP]; + return worldId || 1; // Default to Forest if unknown theme +}; + +/** + * Utility function to get world name from ID + */ +export const getWorldNameFromId = (worldId: number): string => { + return WORLD_ENUM_MAP[worldId as keyof typeof WORLD_ENUM_MAP] || 'Forest'; +}; + +/** + * Utility function to get golem name from ID + */ +export const getGolemNameFromId = (golemId: number): string => { + return GOLEM_ENUM_MAP[golemId as keyof typeof GOLEM_ENUM_MAP] || 'Stone'; +}; \ No newline at end of file