From 9229a4204c453b03bbb1f5036aadcd1d47583883 Mon Sep 17 00:00:00 2001 From: jimenezz22 Date: Sun, 22 Jun 2025 15:45:21 -0600 Subject: [PATCH 1/3] [FEAT] init missions v1 --- client/dev-dist/sw.js | 2 +- .../screens/Home/DailyMissionsModal.tsx | 445 +++++++++++------ .../components/screens/Home/HomeScreen.tsx | 3 +- client/src/components/types/missionTypes.ts | 80 ++- client/src/dojo/hooks/useMissions.tsx | 461 ++++++++++++++++++ client/src/utils/TimeHelpers.ts | 7 +- 6 files changed, 834 insertions(+), 164 deletions(-) create mode 100644 client/src/dojo/hooks/useMissions.tsx diff --git a/client/dev-dist/sw.js b/client/dev-dist/sw.js index c0439f7..7a58a65 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.73g75renlu8" + "revision": "0.99jp5cvcoc" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/client/src/components/screens/Home/DailyMissionsModal.tsx b/client/src/components/screens/Home/DailyMissionsModal.tsx index ee82743..e10fd76 100644 --- a/client/src/components/screens/Home/DailyMissionsModal.tsx +++ b/client/src/components/screens/Home/DailyMissionsModal.tsx @@ -1,60 +1,129 @@ import { motion, AnimatePresence } from "framer-motion" -import { useState } from "react" +import { useState, useEffect, useMemo } from "react" +import { useAccount } from "@starknet-react/core"; +import { addAddressPadding } from "starknet"; import GolemTalkIcon from "../../../assets/icons/GolemTalkIcon.webp" import { ClaimMissionAnimation } from "./ClaimMissionAnimation" import coinIcon from "../../../assets/icons/CoinIcon.webp"; - -interface Mission { - id: string - title: string - description: string - difficulty: 'Easy' | 'Mid' | 'Hard' - reward: number - completed: boolean - claimed?: boolean -} +import { useMissionsInit } from "../../../dojo/hooks/useMissions"; +import { Mission } from "../../../dojo/bindings"; +import { MissionDisplayData } from "../../types/missionTypes"; interface DailyMissionsModalProps { - /** Player's address for context */ - playerAddress: string /** Callback to close the modal */ onClose: () => void } +/** + * Converts Mission bindings to display data for UI + */ +const missionToDisplayData = (mission: Mission): MissionDisplayData => { + // Determine difficulty based on target_coins + let difficulty: 'Easy' | 'Mid' | 'Hard' = 'Easy'; + if (mission.target_coins >= 1000) difficulty = 'Hard'; + else if (mission.target_coins >= 500) difficulty = 'Mid'; + + // Extract world and golem names + const worldVariant = mission.required_world.activeVariant(); + const golemVariant = mission.required_golem.activeVariant(); + + const requiredWorld = worldVariant.charAt(0).toUpperCase() + worldVariant.slice(1); + const requiredGolem = golemVariant.charAt(0).toUpperCase() + golemVariant.slice(1); + + // Check if completed + const completed = mission.status.activeVariant() === 'Completed'; + + // Generate a title from description (first few words) + const title = mission.description.split(' ').slice(0, 3).join(' ') || 'Daily Mission'; + + return { + id: mission.id.toString(), // Convert to string for compatibility + title, + description: mission.description, + difficulty, + reward: mission.target_coins, + requiredWorld, + requiredGolem, + completed, + claimed: false // UI state, will be managed locally + }; +}; + export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) { - const [showCelebration, setShowCelebration] = useState(false) - const [claimedMission, setClaimedMission] = useState(null) - const [missions, setMissions] = useState([ - { - id: "mission_1", - title: "First Steps", - description: "Complete 3 runs in any level", - difficulty: "Easy", - reward: 100, - completed: false, - claimed: false - }, - { - id: "mission_2", - title: "Speed Runner", - description: "Finish a run in under 2 minutes", - difficulty: "Mid", - reward: 250, - completed: false, - claimed: false - }, - { - id: "mission_3", - title: "Coin Master", - description: "Collect 1000 coins in a single run", - difficulty: "Hard", - reward: 500, - completed: true, - claimed: false + // Get account from Starknet directly + const { account } = useAccount(); + + // Memoize user address with proper formatting + const playerAddress = useMemo(() => + account ? addAddressPadding(account.address) : null, + [account] + ); + // Hook para manejo de misiones + const { + todayMissions, + isLoading, + isSpawning, + error, + hasData, + initializeMissions + } = useMissionsInit(); + + // Estado local para UI + const [showCelebration, setShowCelebration] = useState(false); + const [claimedMission, setClaimedMission] = useState(null); + const [claimedMissionIds, setClaimedMissionIds] = useState>(new Set()); + + // Inicializar misiones cuando se abre el modal + useEffect(() => { + if (playerAddress) { + console.log("🚀 Modal opened, initializing missions for:", playerAddress); + initializeMissions(); } - ]) + }, [playerAddress, initializeMissions]); + + // Early return if no account connected + if (!account || !playerAddress) { + return ( + + e.stopPropagation()} + > +

Wallet Required

+

+ Please connect your wallet to view daily missions. +

+ + Close + +
+
+ ); + } - const getDifficultyStyle = (difficulty: Mission['difficulty']) => { + // Convertir misiones de bindings a display data + const displayMissions: MissionDisplayData[] = todayMissions.map(mission => { + const displayData = missionToDisplayData(mission); + // Check if this mission was claimed in this session + displayData.claimed = claimedMissionIds.has(mission.id.toString()); + return displayData; + }); + + const getDifficultyStyle = (difficulty: MissionDisplayData['difficulty']) => { switch (difficulty) { case 'Easy': return 'bg-green-500 text-white' @@ -65,25 +134,31 @@ export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) { default: return 'bg-gray-500 text-white' } - } + }; - const handleClaimReward = (mission: Mission) => { - setClaimedMission(mission) - setShowCelebration(true) + const handleClaimReward = (mission: MissionDisplayData) => { + setClaimedMission(mission); + setShowCelebration(true); - setMissions(prevMissions => - prevMissions.map(m => - m.id === mission.id ? { ...m, claimed: true } : m - ) - ) + // Mark as claimed in local state + setClaimedMissionIds(prev => new Set(prev).add(mission.id)); - console.log(`Claiming reward for mission: ${mission.id}, reward: ${mission.reward}`) - } + console.log(`Claiming reward for mission: ${mission.id}, reward: ${mission.reward}`); + // TODO: Aquí irá la lógica de reward cuando implementemos ese feature + }; const handleCloseCelebration = () => { - setShowCelebration(false) - setClaimedMission(null) - } + setShowCelebration(false); + setClaimedMission(null); + }; + + // Loading states + const showLoading = isLoading || isSpawning; + const loadingText = isSpawning + ? "Creating your daily missions..." + : isLoading + ? "Loading missions..." + : ""; return ( <> @@ -126,113 +201,173 @@ export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) {

Complete missions to earn rewards!

- {/* Lista de misiones */} -
- {missions.map((mission: Mission, index: number) => ( + {/* Estados de carga y error */} + {showLoading && ( +
+

{loadingText}

+ {isSpawning && ( +

+ Generating missions with AI and storing on blockchain... +

+ )} +
+ )} + + {error && !showLoading && ( +
+
+

+ Failed to load missions: {error} +

+
+ initializeMissions()} + className="bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-[5px] font-luckiest text-sm" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} > - {/* Header: Dificultad, Recompensa y Estado */} -
-
- {/* Etiqueta de dificultad */} - - {mission.difficulty} - - - {/* Recompensa */} -
- - {mission.reward} + Try Again + +
+ )} + + {/* Lista de misiones */} + {!showLoading && !error && hasData && ( +
+ {displayMissions.map((mission: MissionDisplayData, index: number) => ( + + {/* Header: Dificultad, Recompensa y Estado */} +
+
+ {/* Etiqueta de dificultad */} + + {mission.difficulty} - Coin Icon + + {/* Recompensa */} +
+ + {mission.reward} + + Coin Icon +
+ + {/* Indicador de completado */} + {mission.completed && ( +
+ +
+ )}
- - {/* Indicador de completado */} - {mission.completed && ( -
+

+ {mission.title} +

+

- + {mission.description} +

+ + {/* Información adicional del mundo y golem requerido */} +
+ 🗺️ {mission.requiredWorld} + 🧌 {mission.requiredGolem} Golem
- )} -
+
- {/* Título y descripción */} -
-

- {mission.title} -

-

- {mission.description} -

-
+ {/* Botón de reclamar */} + {mission.completed && !mission.claimed && ( +
+ handleClaimReward(mission)} + > + Claim Reward + +
+ )} - {/* Botón de reclamar */} - {mission.completed && !mission.claimed && ( -
- handleClaimReward(mission)} - > - Claim Reward - -
- )} + {/* Overlay para misiones completadas */} + {mission.completed && ( +
+ )} + + ))} +
+ )} - {/* Overlay para misiones completadas */} - {mission.completed && ( -
- )} - - ))} -
+ {/* Estado cuando no hay misiones y no está cargando */} + {!showLoading && !error && !hasData && ( +
+

+ No missions found for today +

+ initializeMissions()} + className="bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-[5px] font-luckiest text-sm" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + Generate Missions + +
+ )} {/* Botón para cerrar */} {showTalkModal && (
- +
)} diff --git a/client/src/components/types/missionTypes.ts b/client/src/components/types/missionTypes.ts index d2ea909..4a7a49d 100644 --- a/client/src/components/types/missionTypes.ts +++ b/client/src/components/types/missionTypes.ts @@ -14,10 +14,11 @@ export interface ElizaMissionData { } /** - * Simple display data for the UI (converted from Mission bindings) + * Mission display data for the UI (compatible with ClaimMissionAnimation) + * Using string ID to match existing ClaimMissionAnimation component */ export interface MissionDisplayData { - id: number; + id: string; // Changed from number to string for compatibility title: string; description: string; difficulty: 'Easy' | 'Mid' | 'Hard'; @@ -28,6 +29,79 @@ export interface MissionDisplayData { claimed?: boolean; } +/** + * Parses Eliza response text to mission data + * Eliza returns plain text like: "150, coins, glacier, ice" + * No JSON parsing needed! + */ +export function parseElizaResponse(elizaResponse: string): ElizaMissionData | null { + try { + console.log("🔍 Parsing Eliza response:", elizaResponse); + + // Eliza returns plain text directly, not JSON! + // Split the text by commas and clean up + const parts = elizaResponse.split(',').map(part => part.trim().toLowerCase()); + + if (parts.length < 4) { + throw new Error(`Insufficient parts in Eliza response. Expected 4, got ${parts.length}`); + } + + // Extract values: [target_coins, "coins", world, golem] + const [coinsStr, , worldStr, golemStr] = parts; + + // Parse target coins + const target_coins = parseInt(coinsStr); + if (isNaN(target_coins) || target_coins <= 0) { + throw new Error(`Invalid target_coins: ${coinsStr}`); + } + + // Normalize and validate world + const worldMap: Record = { + 'forest': 'Forest', + 'volcano': 'Volcano', + 'glacier': 'Glacier', + 'ice': 'Glacier', // Alternative name + 'fire': 'Volcano', // Alternative name + 'lava': 'Volcano' // Alternative name + }; + + const required_world = worldMap[worldStr]; + if (!required_world) { + console.warn(`Unknown world "${worldStr}", defaulting to Forest`); + } + + // Normalize and validate golem + const golemMap: Record = { + 'fire': 'Fire', + 'ice': 'Ice', + 'stone': 'Stone', + 'rock': 'Stone' // Alternative name + }; + + const required_golem = golemMap[golemStr]; + if (!required_golem) { + console.warn(`Unknown golem "${golemStr}", defaulting to Stone`); + } + + // Create description + const description = `Collect ${target_coins} coins in the ${(required_world || 'Forest').toLowerCase()} using your ${(required_golem || 'Stone').toLowerCase()} golem`; + + const missionData: ElizaMissionData = { + target_coins, + required_world: required_world || 'Forest', + required_golem: required_golem || 'Stone', + description + }; + + console.log("✅ Successfully parsed Eliza mission:", missionData); + return missionData; + + } catch (error) { + console.error("❌ Error parsing Eliza response:", error); + return null; + } +} + /** * Creates fallback missions if Eliza fails */ @@ -55,7 +129,7 @@ export function createFallbackMissions(): ElizaMissionData[] { } /** - * Validates if Eliza response has the correct structure + * Validates if parsed data is a valid mission */ export function isValidElizaMissionData(data: any): data is ElizaMissionData { return ( diff --git a/client/src/dojo/hooks/useMissions.tsx b/client/src/dojo/hooks/useMissions.tsx new file mode 100644 index 0000000..3f2fcf5 --- /dev/null +++ b/client/src/dojo/hooks/useMissions.tsx @@ -0,0 +1,461 @@ +import { useState, useCallback, useMemo } from "react"; +import { useAccount } from "@starknet-react/core"; +import { addAddressPadding, CairoCustomEnum } from "starknet"; +import { useDojoSDK } from "@dojoengine/sdk/react"; +import { Account } from "starknet"; +import { dojoConfig } from "../dojoConfig"; +import { Mission } from '../bindings'; +import useAppStore from '../../zustand/store'; +import { AIAgentService } from '../../services/aiAgent'; +import { + ElizaMissionData, + createFallbackMissions, + parseElizaResponse +} from '../../components/types/missionTypes'; +import { + getCurrentDay, + getCurrentDayTimestamp, + isMissionCacheStale, + isMissionFromToday +} from '../../utils/TimeHelpers'; + +// Types +interface MissionEdge { + node: RawMissionNode; +} + +interface RawMissionNode { + id: string; + player_id: string; + target_coins: string; + required_world: any; + required_golem: any; + description: string; + status: any; + created_at: string; +} + +interface UseMissionsInitReturn { + // Data + todayMissions: Mission[]; + pendingMissions: Mission[]; + completedMissions: Mission[]; + + // States + isLoading: boolean; + isSpawning: boolean; + error: string | null; + hasData: boolean; + + // Actions + initializeMissions: () => Promise; + refetchMissions: () => Promise; +} + +// Constants +const TORII_URL = dojoConfig.toriiUrl + "/graphql"; +const MISSIONS_QUERY = ` + query GetMissions($playerAddress: ContractAddress!, $dayTimestamp: u32!) { + golemRunnerMissionModels( + where: { + player_id: $playerAddress, + created_at: $dayTimestamp + } + first: 10 + ) { + edges { + node { + id + player_id + target_coins + required_world + required_golem + description + status + created_at + } + } + totalCount + } + } +`; + +// Helper functions +const hexToNumber = (hexValue: string | number): number => { + if (typeof hexValue === 'number') return hexValue; + if (typeof hexValue === 'string' && hexValue.startsWith('0x')) { + return parseInt(hexValue, 16); + } + if (typeof hexValue === 'string') { + return parseInt(hexValue, 10); + } + return 0; +}; + +/** + * Converts raw Torii response to Mission binding format + */ +const toriiNodeToMission = (rawNode: RawMissionNode): Mission => { + // Extract world type from Cairo enum + let required_world: CairoCustomEnum; + if (rawNode.required_world?.Volcano !== undefined) { + required_world = new CairoCustomEnum({ Volcano: "Volcano" }); + } else if (rawNode.required_world?.Glacier !== undefined) { + required_world = new CairoCustomEnum({ Glacier: "Glacier" }); + } else { + required_world = new CairoCustomEnum({ Forest: "Forest" }); + } + + // Extract golem type from Cairo enum + let required_golem: CairoCustomEnum; + if (rawNode.required_golem?.Ice !== undefined) { + required_golem = new CairoCustomEnum({ Ice: "Ice" }); + } else if (rawNode.required_golem?.Stone !== undefined) { + required_golem = new CairoCustomEnum({ Stone: "Stone" }); + } else { + required_golem = new CairoCustomEnum({ Fire: "Fire" }); + } + + // Extract status from Cairo enum + let status: CairoCustomEnum; + if (rawNode.status?.Completed !== undefined) { + status = new CairoCustomEnum({ Completed: "Completed" }); + } else { + status = new CairoCustomEnum({ Pending: "Pending" }); + } + + return { + id: hexToNumber(rawNode.id), + player_id: rawNode.player_id, + target_coins: hexToNumber(rawNode.target_coins), + required_world, + required_golem, + description: rawNode.description, + status, + created_at: hexToNumber(rawNode.created_at) + }; +}; + +/** + * Fetches missions from Torii GraphQL + */ +const fetchMissionsFromTorii = async (playerAddress: string): Promise => { + try { + // 🔍 DEBUG: Ver qué timestamp estamos calculando + const dayTimestamp = getCurrentDayTimestamp(); + const currentDayNumber = getCurrentDay(); // Este debería ser similar a 20261 + + console.log("📡 Fetching missions for player:", playerAddress); + console.log("🕐 Day timestamp (start of day in seconds):", dayTimestamp); + console.log("🕐 Current day number:", currentDayNumber); + console.log("🕐 Blockchain missions have created_at:", 20261); + + // 🛠️ FIX: Usar day number (integer) en lugar de timestamp (string) + // El contrato usa Timestamp::unix_timestamp_to_day() que devuelve el día número + const response = await fetch(TORII_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: MISSIONS_QUERY, + variables: { + playerAddress, + dayTimestamp: currentDayNumber // 🛠️ FIX: usar día número como integer + } + }), + }); + + const result = await response.json(); + console.log("📥 GraphQL missions response:", result); + + if (!result.data?.golemRunnerMissionModels?.edges) { + console.log("ℹ️ No missions found in Torii response"); + return []; + } + + const missions = result.data.golemRunnerMissionModels.edges.map((edge: MissionEdge) => + toriiNodeToMission(edge.node) + ); + + console.log("✅ Parsed missions from Torii:", missions); + return missions; + } catch (error) { + console.error("❌ Error fetching missions from Torii:", error); + throw error; + } +}; + +/** + * Converts ElizaMissionData to Cairo enums for contract + */ +const elizaDataToCairoEnums = (elizaData: ElizaMissionData) => { + const worldMap: Record = { + 'Forest': new CairoCustomEnum({ Forest: "Forest" }), + 'Volcano': new CairoCustomEnum({ Volcano: "Volcano" }), + 'Glacier': new CairoCustomEnum({ Glacier: "Glacier" }) + }; + + const golemMap: Record = { + 'Fire': new CairoCustomEnum({ Fire: "Fire" }), + 'Ice': new CairoCustomEnum({ Ice: "Ice" }), + 'Stone': new CairoCustomEnum({ Stone: "Stone" }) + }; + + return { + target_coins: elizaData.target_coins, + required_world: worldMap[elizaData.required_world] || worldMap['Forest'], + required_golem: golemMap[elizaData.required_golem] || golemMap['Fire'], + description: elizaData.description + }; +}; + +/** + * Main hook - Solo para inicialización de misiones + */ +export const useMissionsInit = (): UseMissionsInitReturn => { + const { client } = useDojoSDK(); + const { account } = useAccount(); + + // Zustand store + const { + missions, + lastMissionFetch, + isMissionsLoading, + setMissions, + setMissionsLoading, + setMissionsError + } = useAppStore(); + + // Local state + const [isSpawning, setIsSpawning] = useState(false); + + // Memoize user address + const userAddress = useMemo(() => + account ? addAddressPadding(account.address) : null, + [account] + ); + + // Memoized derived data - Solo misiones de hoy + const todayMissions = useMemo(() => + missions.filter(mission => isMissionFromToday(mission.created_at)), + [missions] + ); + + const pendingMissions = useMemo(() => + todayMissions.filter(mission => + mission.status.activeVariant() === 'Pending' + ), + [todayMissions] + ); + + const completedMissions = useMemo(() => + todayMissions.filter(mission => + mission.status.activeVariant() === 'Completed' + ), + [todayMissions] + ); + + const hasData = todayMissions.length > 0; + + /** + * Fetch missions from Torii and update store + */ + const refetchMissions = useCallback(async (): Promise => { + if (!userAddress) { + console.log("ℹ️ No user address, skipping mission fetch"); + return; + } + + setMissionsLoading(true); + setMissionsError(null); + + try { + const fetchedMissions = await fetchMissionsFromTorii(userAddress); + setMissions(fetchedMissions); + console.log("✅ Missions successfully fetched and stored"); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to fetch missions"; + setMissionsError(errorMessage); + console.error("❌ Error in refetchMissions:", err); + throw err; + } finally { + setMissionsLoading(false); + } + }, [userAddress, setMissions, setMissionsLoading, setMissionsError]); + + /** + * Generate 3 missions using Eliza and create them in the contract + */ + const spawnNewMissions = useCallback(async (): Promise => { + if (!userAddress || !account) { + console.error("❌ No user address or account for spawning missions"); + return false; + } + + setIsSpawning(true); + setMissionsError(null); + + try { + console.log("🎲 Starting mission spawn process..."); + + // Generate 3 missions from Eliza + const elizaMissions: ElizaMissionData[] = []; + const fallbackMissions = createFallbackMissions(); + + for (let i = 0; i < 3; i++) { + try { + console.log(`🤖 Requesting mission ${i + 1} from Eliza...`); + const elizaResponse = await AIAgentService.getDailyMission(userAddress); + + // Parse Eliza response using our helper + const elizaData = parseElizaResponse(elizaResponse); + + if (elizaData) { + elizaMissions.push(elizaData); + console.log(`✅ Mission ${i + 1} parsed successfully:`, elizaData); + } else { + console.log(`⚠️ Failed to parse mission ${i + 1}, using fallback`); + elizaMissions.push(fallbackMissions[i] || fallbackMissions[0]); + } + + } catch (error) { + console.error(`❌ Error getting mission ${i + 1} from Eliza:`, error); + elizaMissions.push(fallbackMissions[i] || fallbackMissions[0]); + } + } + + console.log("📝 Generated missions:", elizaMissions); + + // Create missions in contract (sequential transactions) + console.log("🔗 Creating missions in contract sequentially..."); + const results = []; + + for (let i = 0; i < elizaMissions.length; i++) { + const elizaData = elizaMissions[i]; + try { + const cairoData = elizaDataToCairoEnums(elizaData); + console.log(`📤 Creating mission ${i + 1}/3:`, cairoData); + + // Usar el patrón correcto: client.game.metodo() + const tx = await client.game.createMission( + account as Account, + cairoData.target_coins, + cairoData.required_world, + cairoData.required_golem, + cairoData.description + ); + + console.log(`📥 Mission ${i + 1} transaction response:`, tx); + + if (tx && tx.code === "SUCCESS") { + results.push({ success: true, mission: elizaData, tx }); + console.log(`✅ Mission ${i + 1} created successfully`); + } else { + results.push({ success: false, mission: elizaData, error: `Transaction failed with code: ${tx?.code}` }); + console.log(`❌ Mission ${i + 1} failed with code:`, tx?.code); + } + + // Small delay between transactions to avoid nonce issues + if (i < elizaMissions.length - 1) { + console.log("⏳ Waiting before next transaction..."); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + } catch (error) { + console.error(`❌ Failed to create mission ${i + 1}:`, error); + results.push({ + success: false, + mission: elizaData, + error: error instanceof Error ? error.message : "Unknown error" + }); + } + } + + // Check results + const successful = results.filter(result => result.success).length; + const failed = results.filter(result => !result.success).length; + + console.log(`📊 Mission creation results: ${successful}/3 successful, ${failed}/3 failed`); + + if (successful === 0) { + throw new Error("All mission creation transactions failed"); + } + + if (failed > 0) { + console.warn(`⚠️ ${failed} mission(s) failed to create, but continuing with ${successful} successful mission(s)`); + } + + // Wait a bit for blockchain to process + console.log("⏳ Waiting for blockchain to process..."); + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Refetch missions from Torii + console.log("🔄 Refetching missions from Torii..."); + await refetchMissions(); + + console.log("🎉 Mission spawn process completed successfully"); + return true; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Failed to spawn missions"; + setMissionsError(errorMessage); + console.error("❌ Error spawning missions:", error); + return false; + } finally { + setIsSpawning(false); + } + }, [userAddress, account, client.game, refetchMissions, setMissionsError]); + + /** + * Main initialization function - checks cache, fetches from Torii, or spawns new missions + */ + const initializeMissions = useCallback(async (): Promise => { + if (!userAddress) { + console.log("ℹ️ No user address, skipping mission initialization"); + return false; + } + + console.log("🚀 Initializing missions..."); + + // 1. Check if cache is fresh + if (!isMissionCacheStale(lastMissionFetch) && todayMissions.length > 0) { + console.log("✅ Using cached missions"); + return true; + } + + // 2. Try to fetch from Torii + console.log("📡 Fetching missions from Torii..."); + try { + await refetchMissions(); + + // 3. If no missions found after fetch, spawn new ones + if (todayMissions.length === 0) { + console.log("🎲 No missions found, spawning new ones..."); + return await spawnNewMissions(); + } + + console.log("✅ Missions initialized successfully"); + return true; + } catch (error) { + console.error("❌ Error fetching from Torii, trying to spawn:", error); + // If fetch fails, try to spawn new missions + return await spawnNewMissions(); + } + }, [userAddress, lastMissionFetch, todayMissions.length, refetchMissions, spawnNewMissions]); + + return { + // Data + todayMissions, + pendingMissions, + completedMissions, + + // States + isLoading: isMissionsLoading, + isSpawning, + error: useAppStore.getState().missionsError, + hasData, + + // Actions + initializeMissions, + refetchMissions + }; +}; \ No newline at end of file diff --git a/client/src/utils/TimeHelpers.ts b/client/src/utils/TimeHelpers.ts index 268fbf9..e4482e5 100644 --- a/client/src/utils/TimeHelpers.ts +++ b/client/src/utils/TimeHelpers.ts @@ -33,7 +33,8 @@ export function getCurrentDayTimestamp(): number { * @returns Current day number since Unix epoch */ export function getCurrentDay(): number { - return unixTimestampToDay(getCurrentDayTimestamp()); + const now = Math.floor(Date.now() / 1000); + return Math.floor(now / SECONDS_PER_DAY); } /** @@ -54,9 +55,9 @@ export function isDifferentDay(timestamp1: number, timestamp2: number): boolean * @returns true if mission was created today */ export function isMissionFromToday(missionCreatedAt: number): boolean { - const missionDay = unixTimestampToDay(missionCreatedAt); const currentDay = getCurrentDay(); - return missionDay === currentDay; + console.log(`🔍 Comparing mission day ${missionCreatedAt} with current day ${currentDay}`); + return missionCreatedAt === currentDay; } /** From 7bb623e84193e6e14490efb1d3979494515cec58 Mon Sep 17 00:00:00 2001 From: jimenezz22 Date: Sun, 22 Jun 2025 16:44:34 -0600 Subject: [PATCH 2/3] init missions v2 --- client/dev-dist/sw.js | 2 +- .../screens/Home/DailyMissionsModal.tsx | 57 ++- client/src/dojo/hooks/useMissions.tsx | 340 +++++++++++------- client/src/utils/TimeHelpers.ts | 60 +++- client/src/zustand/store.ts | 2 - 5 files changed, 316 insertions(+), 145 deletions(-) diff --git a/client/dev-dist/sw.js b/client/dev-dist/sw.js index 7a58a65..fb2379f 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.99jp5cvcoc" + "revision": "0.3cel621ign8" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/client/src/components/screens/Home/DailyMissionsModal.tsx b/client/src/components/screens/Home/DailyMissionsModal.tsx index e10fd76..08d03ee 100644 --- a/client/src/components/screens/Home/DailyMissionsModal.tsx +++ b/client/src/components/screens/Home/DailyMissionsModal.tsx @@ -8,6 +8,7 @@ import coinIcon from "../../../assets/icons/CoinIcon.webp"; import { useMissionsInit } from "../../../dojo/hooks/useMissions"; import { Mission } from "../../../dojo/bindings"; import { MissionDisplayData } from "../../types/missionTypes"; +//import { debugTimeCalculations } from '../../../utils/TimeHelpers'; interface DailyMissionsModalProps { /** Callback to close the modal */ @@ -15,28 +16,68 @@ interface DailyMissionsModalProps { } /** - * Converts Mission bindings to display data for UI + * 🛠️ FIXED: Safe function to extract enum variant + */ +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 property format {variant: {Key: 'Value'}} + if (enumObj.variant && typeof enumObj.variant === 'object') { + const keys = Object.keys(enumObj.variant); + if (keys.length > 0) { + return keys[0]; // Return first key + } + } + + // Try direct object format {Key: 'Value'} + if (typeof enumObj === 'object') { + const keys = Object.keys(enumObj); + if (keys.length > 0) { + return keys[0]; // Return first key + } + } + + // Fallback + return defaultValue; +}; + +/** + * 🛠️ FIXED: Converts Mission bindings to display data for UI */ const missionToDisplayData = (mission: Mission): MissionDisplayData => { + console.log("🔍 Converting mission to display data:", mission); + // Determine difficulty based on target_coins let difficulty: 'Easy' | 'Mid' | 'Hard' = 'Easy'; if (mission.target_coins >= 1000) difficulty = 'Hard'; else if (mission.target_coins >= 500) difficulty = 'Mid'; - // Extract world and golem names - const worldVariant = mission.required_world.activeVariant(); - const golemVariant = mission.required_golem.activeVariant(); + // 🛠️ FIXED: Safe extraction of world and golem variants + const worldVariant = getEnumVariant(mission.required_world, 'Forest'); + const golemVariant = getEnumVariant(mission.required_golem, 'Fire'); + const statusVariant = getEnumVariant(mission.status, 'Pending'); + + console.log("🔍 Extracted variants:", { worldVariant, golemVariant, statusVariant }); const requiredWorld = worldVariant.charAt(0).toUpperCase() + worldVariant.slice(1); const requiredGolem = golemVariant.charAt(0).toUpperCase() + golemVariant.slice(1); // Check if completed - const completed = mission.status.activeVariant() === 'Completed'; + const completed = statusVariant === 'Completed'; // Generate a title from description (first few words) const title = mission.description.split(' ').slice(0, 3).join(' ') || 'Daily Mission'; - return { + const displayData: MissionDisplayData = { id: mission.id.toString(), // Convert to string for compatibility title, description: mission.description, @@ -47,6 +88,9 @@ const missionToDisplayData = (mission: Mission): MissionDisplayData => { completed, claimed: false // UI state, will be managed locally }; + + console.log("✅ Created display data:", displayData); + return displayData; }; export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) { @@ -77,6 +121,7 @@ export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) { useEffect(() => { if (playerAddress) { console.log("🚀 Modal opened, initializing missions for:", playerAddress); + //debugTimeCalculations(); initializeMissions(); } }, [playerAddress, initializeMissions]); diff --git a/client/src/dojo/hooks/useMissions.tsx b/client/src/dojo/hooks/useMissions.tsx index 3f2fcf5..f51545c 100644 --- a/client/src/dojo/hooks/useMissions.tsx +++ b/client/src/dojo/hooks/useMissions.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useEffect } from "react"; import { useAccount } from "@starknet-react/core"; import { addAddressPadding, CairoCustomEnum } from "starknet"; import { useDojoSDK } from "@dojoengine/sdk/react"; @@ -13,8 +13,7 @@ import { parseElizaResponse } from '../../components/types/missionTypes'; import { - getCurrentDay, - getCurrentDayTimestamp, + getCurrentDay, isMissionCacheStale, isMissionFromToday } from '../../utils/TimeHelpers'; @@ -93,38 +92,58 @@ const hexToNumber = (hexValue: string | number): number => { }; /** - * Converts raw Torii response to Mission binding format + * Helper function to safely create CairoCustomEnum */ -const toriiNodeToMission = (rawNode: RawMissionNode): Mission => { - // Extract world type from Cairo enum - let required_world: CairoCustomEnum; - if (rawNode.required_world?.Volcano !== undefined) { - required_world = new CairoCustomEnum({ Volcano: "Volcano" }); - } else if (rawNode.required_world?.Glacier !== undefined) { - required_world = new CairoCustomEnum({ Glacier: "Glacier" }); - } else { - required_world = new CairoCustomEnum({ Forest: "Forest" }); +const createCairoEnum = (rawValue: any, enumMap: Record, defaultValue: string): CairoCustomEnum => { + // Handle null/undefined + if (!rawValue) { + return new CairoCustomEnum({ [defaultValue]: defaultValue }); } - - // Extract golem type from Cairo enum - let required_golem: CairoCustomEnum; - if (rawNode.required_golem?.Ice !== undefined) { - required_golem = new CairoCustomEnum({ Ice: "Ice" }); - } else if (rawNode.required_golem?.Stone !== undefined) { - required_golem = new CairoCustomEnum({ Stone: "Stone" }); - } else { - required_golem = new CairoCustomEnum({ Fire: "Fire" }); + + // If it's already a string + if (typeof rawValue === 'string') { + const enumKey = enumMap[rawValue] ? rawValue : defaultValue; + return new CairoCustomEnum({ [enumKey]: enumKey }); } - - // Extract status from Cairo enum - let status: CairoCustomEnum; - if (rawNode.status?.Completed !== undefined) { - status = new CairoCustomEnum({ Completed: "Completed" }); - } else { - status = new CairoCustomEnum({ Pending: "Pending" }); + + // Handle Torii object format {Pending: {}} or {variant: {Pending: 'Pending'}} + if (typeof rawValue === 'object') { + // Check if it has variant property first + if (rawValue.variant && typeof rawValue.variant === 'object') { + for (const [key, value] of Object.entries(rawValue.variant)) { + if (enumMap[key]) { + return new CairoCustomEnum({ [key]: key }); + } + } + } + + // Check direct object format {Pending: {}} + for (const [key, value] of Object.entries(rawValue)) { + if (enumMap[key]) { + return new CairoCustomEnum({ [key]: key }); + } + } } + + // Fallback to default + return new CairoCustomEnum({ [defaultValue]: defaultValue }); +}; - return { +/** + * Converts raw Torii response to Mission binding format + */ +const toriiNodeToMission = (rawNode: RawMissionNode): Mission => { + // Enum maps + const worldEnumMap = { Volcano: "Volcano", Glacier: "Glacier", Forest: "Forest" }; + const golemEnumMap = { Ice: "Ice", Stone: "Stone", Fire: "Fire" }; + const statusEnumMap = { Completed: "Completed", Pending: "Pending" }; + + // Create enums safely + const required_world = createCairoEnum(rawNode.required_world, worldEnumMap, "Forest"); + const required_golem = createCairoEnum(rawNode.required_golem, golemEnumMap, "Fire"); + const status = createCairoEnum(rawNode.status, statusEnumMap, "Pending"); + + const mission: Mission = { id: hexToNumber(rawNode.id), player_id: rawNode.player_id, target_coins: hexToNumber(rawNode.target_coins), @@ -134,6 +153,29 @@ const toriiNodeToMission = (rawNode: RawMissionNode): Mission => { status, created_at: hexToNumber(rawNode.created_at) }; + + // Test and patch status if needed + try { + mission.status.activeVariant(); + } catch (error) { + // If activeVariant fails, create a working enum manually + let statusKey = "Pending"; // default + + if (mission.status && typeof mission.status === 'object') { + const statusObj = mission.status as any; + + if (statusObj.variant) { + if (statusObj.variant.Pending !== undefined) statusKey = "Pending"; + else if (statusObj.variant.Completed !== undefined) statusKey = "Completed"; + } + else if (statusObj.Pending !== undefined) statusKey = "Pending"; + else if (statusObj.Completed !== undefined) statusKey = "Completed"; + } + + mission.status = new CairoCustomEnum({ [statusKey]: statusKey }); + } + + return mission; }; /** @@ -141,17 +183,8 @@ const toriiNodeToMission = (rawNode: RawMissionNode): Mission => { */ const fetchMissionsFromTorii = async (playerAddress: string): Promise => { try { - // 🔍 DEBUG: Ver qué timestamp estamos calculando - const dayTimestamp = getCurrentDayTimestamp(); - const currentDayNumber = getCurrentDay(); // Este debería ser similar a 20261 - - console.log("📡 Fetching missions for player:", playerAddress); - console.log("🕐 Day timestamp (start of day in seconds):", dayTimestamp); - console.log("🕐 Current day number:", currentDayNumber); - console.log("🕐 Blockchain missions have created_at:", 20261); + const currentDay = getCurrentDay(); - // 🛠️ FIX: Usar day number (integer) en lugar de timestamp (string) - // El contrato usa Timestamp::unix_timestamp_to_day() que devuelve el día número const response = await fetch(TORII_URL, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -159,24 +192,21 @@ const fetchMissionsFromTorii = async (playerAddress: string): Promise query: MISSIONS_QUERY, variables: { playerAddress, - dayTimestamp: currentDayNumber // 🛠️ FIX: usar día número como integer + dayTimestamp: currentDay } }), }); const result = await response.json(); - console.log("📥 GraphQL missions response:", result); if (!result.data?.golemRunnerMissionModels?.edges) { - console.log("ℹ️ No missions found in Torii response"); return []; } - const missions = result.data.golemRunnerMissionModels.edges.map((edge: MissionEdge) => - toriiNodeToMission(edge.node) - ); + const missions = result.data.golemRunnerMissionModels.edges.map((edge: MissionEdge) => { + return toriiNodeToMission(edge.node); + }); - console.log("✅ Parsed missions from Torii:", missions); return missions; } catch (error) { console.error("❌ Error fetching missions from Torii:", error); @@ -227,6 +257,11 @@ export const useMissionsInit = (): UseMissionsInitReturn => { // Local state const [isSpawning, setIsSpawning] = useState(false); + const [spawnAttempts, setSpawnAttempts] = useState(0); + const [lastSpawnTime, setLastSpawnTime] = useState(0); + + const MAX_SPAWN_ATTEMPTS = 1; // Solo un intento por sesión + const MIN_SPAWN_INTERVAL = 30000; // 30 segundos entre intentos // Memoize user address const userAddress = useMemo(() => @@ -234,34 +269,104 @@ export const useMissionsInit = (): UseMissionsInitReturn => { [account] ); - // Memoized derived data - Solo misiones de hoy - const todayMissions = useMemo(() => - missions.filter(mission => isMissionFromToday(mission.created_at)), - [missions] - ); + // Memoized derived data with defensive programming + const todayMissions = useMemo(() => { + if (!Array.isArray(missions)) { + return []; + } + + return missions.filter(mission => { + try { + return isMissionFromToday(mission.created_at); + } catch (error) { + return false; + } + }); + }, [missions]); - const pendingMissions = useMemo(() => - todayMissions.filter(mission => - mission.status.activeVariant() === 'Pending' - ), - [todayMissions] - ); + const pendingMissions = useMemo(() => { + if (!Array.isArray(todayMissions)) { + return []; + } + + return todayMissions.filter((mission) => { + try { + let statusVariant = "Pending"; // default assumption + + if (mission.status && typeof mission.status.activeVariant === 'function') { + statusVariant = mission.status.activeVariant(); + } else if (mission.status && typeof mission.status === 'object') { + const statusObj = mission.status as any; + + if (statusObj.variant) { + if (statusObj.variant.Pending !== undefined) statusVariant = "Pending"; + else if (statusObj.variant.Completed !== undefined) statusVariant = "Completed"; + } + else if (statusObj.Pending !== undefined) statusVariant = "Pending"; + else if (statusObj.Completed !== undefined) statusVariant = "Completed"; + } + + return statusVariant === 'Pending'; + + } catch (error) { + return true; // Assume pending on error + } + }); + }, [todayMissions]); - const completedMissions = useMemo(() => - todayMissions.filter(mission => - mission.status.activeVariant() === 'Completed' - ), - [todayMissions] - ); + const completedMissions = useMemo(() => { + try { + return todayMissions.filter(mission => { + try { + let statusVariant = "Pending"; + + if (mission.status && typeof mission.status.activeVariant === 'function') { + statusVariant = mission.status.activeVariant(); + } else if (mission.status && typeof mission.status === 'object') { + const statusObj = mission.status as any; + + if (statusObj.variant) { + if (statusObj.variant.Completed !== undefined) statusVariant = "Completed"; + else if (statusObj.variant.Pending !== undefined) statusVariant = "Pending"; + } + else if (statusObj.Completed !== undefined) statusVariant = "Completed"; + else if (statusObj.Pending !== undefined) statusVariant = "Pending"; + } + + return statusVariant === 'Completed'; + } catch (error) { + return false; + } + }); + } catch (error) { + return []; + } + }, [todayMissions]); const hasData = todayMissions.length > 0; + /** + * Check if we can spawn (rate limiting) + */ + const canSpawn = useCallback((): boolean => { + const now = Date.now(); + + if (spawnAttempts >= MAX_SPAWN_ATTEMPTS) { + return false; + } + + if (lastSpawnTime && (now - lastSpawnTime) < MIN_SPAWN_INTERVAL) { + return false; + } + + return true; + }, [spawnAttempts, lastSpawnTime]); + /** * Fetch missions from Torii and update store */ const refetchMissions = useCallback(async (): Promise => { if (!userAddress) { - console.log("ℹ️ No user address, skipping mission fetch"); return; } @@ -271,11 +376,9 @@ export const useMissionsInit = (): UseMissionsInitReturn => { try { const fetchedMissions = await fetchMissionsFromTorii(userAddress); setMissions(fetchedMissions); - console.log("✅ Missions successfully fetched and stored"); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Failed to fetch missions"; setMissionsError(errorMessage); - console.error("❌ Error in refetchMissions:", err); throw err; } finally { setMissionsLoading(false); @@ -283,59 +386,53 @@ export const useMissionsInit = (): UseMissionsInitReturn => { }, [userAddress, setMissions, setMissionsLoading, setMissionsError]); /** - * Generate 3 missions using Eliza and create them in the contract + * Generate missions with error handling and tracking */ const spawnNewMissions = useCallback(async (): Promise => { if (!userAddress || !account) { - console.error("❌ No user address or account for spawning missions"); + return false; + } + + if (!canSpawn()) { + setMissionsError("Mission generation is temporarily limited. Please try again later."); return false; } setIsSpawning(true); setMissionsError(null); + + // Update tracking + setSpawnAttempts(prev => prev + 1); + setLastSpawnTime(Date.now()); try { - console.log("🎲 Starting mission spawn process..."); - // Generate 3 missions from Eliza const elizaMissions: ElizaMissionData[] = []; const fallbackMissions = createFallbackMissions(); for (let i = 0; i < 3; i++) { try { - console.log(`🤖 Requesting mission ${i + 1} from Eliza...`); const elizaResponse = await AIAgentService.getDailyMission(userAddress); - - // Parse Eliza response using our helper const elizaData = parseElizaResponse(elizaResponse); if (elizaData) { elizaMissions.push(elizaData); - console.log(`✅ Mission ${i + 1} parsed successfully:`, elizaData); } else { - console.log(`⚠️ Failed to parse mission ${i + 1}, using fallback`); elizaMissions.push(fallbackMissions[i] || fallbackMissions[0]); } - } catch (error) { - console.error(`❌ Error getting mission ${i + 1} from Eliza:`, error); elizaMissions.push(fallbackMissions[i] || fallbackMissions[0]); } } - console.log("📝 Generated missions:", elizaMissions); - // Create missions in contract (sequential transactions) - console.log("🔗 Creating missions in contract sequentially..."); const results = []; for (let i = 0; i < elizaMissions.length; i++) { const elizaData = elizaMissions[i]; try { const cairoData = elizaDataToCairoEnums(elizaData); - console.log(`📤 Creating mission ${i + 1}/3:`, cairoData); - // Usar el patrón correcto: client.game.metodo() const tx = await client.game.createMission( account as Account, cairoData.target_coins, @@ -344,24 +441,18 @@ export const useMissionsInit = (): UseMissionsInitReturn => { cairoData.description ); - console.log(`📥 Mission ${i + 1} transaction response:`, tx); - if (tx && tx.code === "SUCCESS") { results.push({ success: true, mission: elizaData, tx }); - console.log(`✅ Mission ${i + 1} created successfully`); } else { results.push({ success: false, mission: elizaData, error: `Transaction failed with code: ${tx?.code}` }); - console.log(`❌ Mission ${i + 1} failed with code:`, tx?.code); } // Small delay between transactions to avoid nonce issues if (i < elizaMissions.length - 1) { - console.log("⏳ Waiting before next transaction..."); await new Promise(resolve => setTimeout(resolve, 1000)); } } catch (error) { - console.error(`❌ Failed to create mission ${i + 1}:`, error); results.push({ success: false, mission: elizaData, @@ -372,75 +463,84 @@ export const useMissionsInit = (): UseMissionsInitReturn => { // Check results const successful = results.filter(result => result.success).length; - const failed = results.filter(result => !result.success).length; - - console.log(`📊 Mission creation results: ${successful}/3 successful, ${failed}/3 failed`); if (successful === 0) { throw new Error("All mission creation transactions failed"); } - - if (failed > 0) { - console.warn(`⚠️ ${failed} mission(s) failed to create, but continuing with ${successful} successful mission(s)`); - } - // Wait a bit for blockchain to process - console.log("⏳ Waiting for blockchain to process..."); - await new Promise(resolve => setTimeout(resolve, 3000)); + // Wait for blockchain to process + await new Promise(resolve => setTimeout(resolve, 8000)); // Refetch missions from Torii - console.log("🔄 Refetching missions from Torii..."); await refetchMissions(); - console.log("🎉 Mission spawn process completed successfully"); return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Failed to spawn missions"; setMissionsError(errorMessage); - console.error("❌ Error spawning missions:", error); return false; } finally { setIsSpawning(false); } - }, [userAddress, account, client.game, refetchMissions, setMissionsError]); + }, [userAddress, account, client.game, refetchMissions, setMissionsError, canSpawn]); /** - * Main initialization function - checks cache, fetches from Torii, or spawns new missions + * Main initialization function */ const initializeMissions = useCallback(async (): Promise => { if (!userAddress) { - console.log("ℹ️ No user address, skipping mission initialization"); return false; } - console.log("🚀 Initializing missions..."); - - // 1. Check if cache is fresh + // 1. Check if cache is fresh and has today's missions if (!isMissionCacheStale(lastMissionFetch) && todayMissions.length > 0) { - console.log("✅ Using cached missions"); return true; } - // 2. Try to fetch from Torii - console.log("📡 Fetching missions from Torii..."); + // 2. Always fetch from Torii first (fresh data) try { await refetchMissions(); - // 3. If no missions found after fetch, spawn new ones - if (todayMissions.length === 0) { - console.log("🎲 No missions found, spawning new ones..."); + // 3. After refetch, check if we have today's missions + const currentTodayMissions = missions.filter(mission => isMissionFromToday(mission.created_at)); + + if (currentTodayMissions.length > 0) { + return true; + } + + // 4. Only spawn if we can and have no missions + if (canSpawn()) { return await spawnNewMissions(); + } else { + setMissionsError("No daily missions available. Generation is temporarily limited."); + return false; } - console.log("✅ Missions initialized successfully"); - return true; } catch (error) { - console.error("❌ Error fetching from Torii, trying to spawn:", error); - // If fetch fails, try to spawn new missions - return await spawnNewMissions(); + // If fetch fails and we can spawn, try to create new missions + if (canSpawn()) { + return await spawnNewMissions(); + } else { + setMissionsError("Failed to load missions and generation is rate-limited."); + return false; + } + } + }, [userAddress, lastMissionFetch, todayMissions.length, missions, refetchMissions, spawnNewMissions, canSpawn, setMissionsError]); + + // 🛠️ ANTI-CICLO: Solo reset spawn tracking cuando cambia userAddress + useEffect(() => { + setSpawnAttempts(0); + setLastSpawnTime(0); + }, [userAddress]); + + // 🛠️ ANTI-CICLO: Solo clear missions cuando userAddress cambia y existe + useEffect(() => { + if (userAddress) { + useAppStore.getState().clearMissions(); + setMissionsError(null); } - }, [userAddress, lastMissionFetch, todayMissions.length, refetchMissions, spawnNewMissions]); + }, [userAddress, setMissionsError]); return { // Data diff --git a/client/src/utils/TimeHelpers.ts b/client/src/utils/TimeHelpers.ts index e4482e5..ace07c3 100644 --- a/client/src/utils/TimeHelpers.ts +++ b/client/src/utils/TimeHelpers.ts @@ -8,8 +8,8 @@ export const SECONDS_PER_DAY = 86400; // 24 * 60 * 60 export const MILLISECONDS_PER_DAY = SECONDS_PER_DAY * 1000; /** - * Converts a Unix timestamp (in seconds) to day number - * Matches the Cairo implementation: timestamp / SECONDS_PER_DAY + * Converts a Unix timestamp (in seconds) to day number + * 🛠️ FIX: This should match Cairo exactly * @param timestamp Unix timestamp in seconds * @returns Day number since Unix epoch */ @@ -18,23 +18,25 @@ export function unixTimestampToDay(timestamp: number): number { } /** - * Gets the current day timestamp (start of day in seconds) - * This is what we'll use to query missions for "today" - * @returns Unix timestamp for start of current day in seconds + * 🛠️ FIX: Gets the current day NUMBER (not timestamp) + * This matches exactly what the Cairo contract does: + * Timestamp::unix_timestamp_to_day(current_timestamp) + * @returns Day number since Unix epoch (like 20261) */ -export function getCurrentDayTimestamp(): number { - const now = new Date(); - const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - return Math.floor(startOfDay.getTime() / 1000); // Convert to seconds +export function getCurrentDay(): number { + const nowSeconds = Math.floor(Date.now() / 1000); + return Math.floor(nowSeconds / SECONDS_PER_DAY); } /** - * Gets the current day number - * @returns Current day number since Unix epoch + * 🛠️ FIX: Gets current day timestamp for START of day + * This is different from getCurrentDay() - this gives start of day timestamp + * @returns Unix timestamp for start of current day in seconds */ -export function getCurrentDay(): number { - const now = Math.floor(Date.now() / 1000); - return Math.floor(now / SECONDS_PER_DAY); +export function getCurrentDayTimestamp(): number { + const now = new Date(); + const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + return Math.floor(startOfDay.getTime() / 1000); } /** @@ -50,8 +52,9 @@ export function isDifferentDay(timestamp1: number, timestamp2: number): boolean } /** - * Checks if a mission was created today - * @param missionCreatedAt Mission's created_at timestamp (from contract, in seconds) + * 🛠️ FIX: Checks if a mission was created today + * Mission created_at is stored as DAY NUMBER in Cairo contract + * @param missionCreatedAt Mission's created_at (day number from contract) * @returns true if mission was created today */ export function isMissionFromToday(missionCreatedAt: number): boolean { @@ -109,6 +112,31 @@ export function getDayTimestamp(daysOffset: number = 0): number { return Math.floor(targetDate.getTime() / 1000); } +/** + * 🛠️ DEBUG: Function to validate our calculations + */ +export function debugTimeCalculations() { + const now = Date.now(); + const nowSeconds = Math.floor(now / 1000); + const currentDay = getCurrentDay(); + const currentDayTimestamp = getCurrentDayTimestamp(); + + console.log('=== Time Debug - Expected to match blockchain ==='); + console.log('Current time (ms):', now); + console.log('Current time (seconds):', nowSeconds); + console.log('Current DAY NUMBER (for GraphQL):', currentDay); + console.log('Start of day timestamp:', currentDayTimestamp); + console.log('Blockchain mission created_at example:', 20261); + console.log('Do they match timezone?', currentDay, 'vs', 20261); + + // Calculate what day 20261 represents + const blockchainDayAsDate = new Date(20261 * SECONDS_PER_DAY * 1000); + console.log('Blockchain day 20261 represents:', blockchainDayAsDate.toDateString()); + + const todayAsDay = getCurrentDay(); + console.log('Today as day number:', todayAsDay); +} + // Debug utility to validate our time calculations export function debugTimeUtils() { const now = Date.now(); diff --git a/client/src/zustand/store.ts b/client/src/zustand/store.ts index ad71b5e..80c0ad2 100644 --- a/client/src/zustand/store.ts +++ b/client/src/zustand/store.ts @@ -230,8 +230,6 @@ const useAppStore = create()( worlds: state.worlds, currentGolem: state.currentGolem, currentWorld: state.currentWorld, - missions: state.missions, - lastMissionFetch: state.lastMissionFetch, // persist last fetch time }), } ) From a3f2af6f4a56194b190ec249aba418c0d4673570 Mon Sep 17 00:00:00 2001 From: jimenezz22 Date: Sun, 22 Jun 2025 17:43:09 -0600 Subject: [PATCH 3/3] refactor: remove useMissions hook and split functionality into useMissionData, useMissionQuery, and useMissionSpawner - Deleted the useMissions hook and its associated logic. - Introduced useMissionData for mission filtering and data handling. - Created useMissionQuery for fetching missions from the GraphQL API. - Added useMissionSpawner for spawning new missions with Eliza's AI. - Simplified AIAgentService by removing database caching logic. - Updated mission handling and error management across hooks. --- client/dev-dist/sw.js | 2 +- .../screens/Home/DailyMissionsModal.tsx | 127 ++-- client/src/dojo/hooks/useMissionData.tsx | 84 +++ client/src/dojo/hooks/useMissionQuery.tsx | 185 ++++++ client/src/dojo/hooks/useMissionSpawner.tsx | 149 +++++ client/src/dojo/hooks/useMissions.tsx | 561 ------------------ client/src/services/aiAgent.ts | 56 +- 7 files changed, 486 insertions(+), 678 deletions(-) create mode 100644 client/src/dojo/hooks/useMissionData.tsx create mode 100644 client/src/dojo/hooks/useMissionQuery.tsx create mode 100644 client/src/dojo/hooks/useMissionSpawner.tsx delete mode 100644 client/src/dojo/hooks/useMissions.tsx diff --git a/client/dev-dist/sw.js b/client/dev-dist/sw.js index fb2379f..eb2981b 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.3cel621ign8" + "revision": "0.g8ftgsh6a18" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/client/src/components/screens/Home/DailyMissionsModal.tsx b/client/src/components/screens/Home/DailyMissionsModal.tsx index 08d03ee..567f291 100644 --- a/client/src/components/screens/Home/DailyMissionsModal.tsx +++ b/client/src/components/screens/Home/DailyMissionsModal.tsx @@ -1,14 +1,15 @@ import { motion, AnimatePresence } from "framer-motion" -import { useState, useEffect, useMemo } from "react" +import { useState, useEffect, useMemo, useCallback } from "react" import { useAccount } from "@starknet-react/core"; import { addAddressPadding } from "starknet"; import GolemTalkIcon from "../../../assets/icons/GolemTalkIcon.webp" import { ClaimMissionAnimation } from "./ClaimMissionAnimation" import coinIcon from "../../../assets/icons/CoinIcon.webp"; -import { useMissionsInit } from "../../../dojo/hooks/useMissions"; import { Mission } from "../../../dojo/bindings"; import { MissionDisplayData } from "../../types/missionTypes"; -//import { debugTimeCalculations } from '../../../utils/TimeHelpers'; +import { useMissionQuery } from "../../../dojo/hooks/useMissionQuery"; +import { useMissionSpawner } from "../../../dojo/hooks/useMissionSpawner"; +import { useMissionData } from "../../../dojo/hooks/useMissionData"; interface DailyMissionsModalProps { /** Callback to close the modal */ @@ -16,12 +17,11 @@ interface DailyMissionsModalProps { } /** - * 🛠️ FIXED: Safe function to extract enum variant + * Safe function to extract enum variant */ const getEnumVariant = (enumObj: any, defaultValue: string): string => { if (!enumObj) return defaultValue; - // Try activeVariant function first if (typeof enumObj.activeVariant === 'function') { try { return enumObj.activeVariant(); @@ -30,55 +30,42 @@ const getEnumVariant = (enumObj: any, defaultValue: string): string => { } } - // Try variant property format {variant: {Key: 'Value'}} if (enumObj.variant && typeof enumObj.variant === 'object') { const keys = Object.keys(enumObj.variant); if (keys.length > 0) { - return keys[0]; // Return first key + return keys[0]; } } - // Try direct object format {Key: 'Value'} if (typeof enumObj === 'object') { const keys = Object.keys(enumObj); if (keys.length > 0) { - return keys[0]; // Return first key + return keys[0]; } } - // Fallback return defaultValue; }; /** - * 🛠️ FIXED: Converts Mission bindings to display data for UI + * Converts Mission bindings to display data for UI */ const missionToDisplayData = (mission: Mission): MissionDisplayData => { - console.log("🔍 Converting mission to display data:", mission); - - // Determine difficulty based on target_coins let difficulty: 'Easy' | 'Mid' | 'Hard' = 'Easy'; if (mission.target_coins >= 1000) difficulty = 'Hard'; else if (mission.target_coins >= 500) difficulty = 'Mid'; - // 🛠️ FIXED: Safe extraction of world and golem variants const worldVariant = getEnumVariant(mission.required_world, 'Forest'); const golemVariant = getEnumVariant(mission.required_golem, 'Fire'); const statusVariant = getEnumVariant(mission.status, 'Pending'); - console.log("🔍 Extracted variants:", { worldVariant, golemVariant, statusVariant }); - const requiredWorld = worldVariant.charAt(0).toUpperCase() + worldVariant.slice(1); const requiredGolem = golemVariant.charAt(0).toUpperCase() + golemVariant.slice(1); - - // Check if completed const completed = statusVariant === 'Completed'; - - // Generate a title from description (first few words) const title = mission.description.split(' ').slice(0, 3).join(' ') || 'Daily Mission'; const displayData: MissionDisplayData = { - id: mission.id.toString(), // Convert to string for compatibility + id: mission.id.toString(), title, description: mission.description, difficulty, @@ -86,47 +73,79 @@ const missionToDisplayData = (mission: Mission): MissionDisplayData => { requiredWorld, requiredGolem, completed, - claimed: false // UI state, will be managed locally + claimed: false }; - console.log("✅ Created display data:", displayData); return displayData; }; export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) { - // Get account from Starknet directly const { account } = useAccount(); - // Memoize user address with proper formatting const playerAddress = useMemo(() => account ? addAddressPadding(account.address) : null, [account] ); - // Hook para manejo de misiones - const { - todayMissions, - isLoading, - isSpawning, - error, - hasData, - initializeMissions - } = useMissionsInit(); - // Estado local para UI + // Hooks modulares + const { fetchTodayMissions, isLoading: isQuerying, error: queryError } = useMissionQuery(); + const { spawnMissions, isSpawning, error: spawnError } = useMissionSpawner(); + + // Estado local + const [missions, setMissions] = useState([]); + const [isInitialized, setIsInitialized] = useState(false); const [showCelebration, setShowCelebration] = useState(false); const [claimedMission, setClaimedMission] = useState(null); const [claimedMissionIds, setClaimedMissionIds] = useState>(new Set()); + + // Procesar data + const { todayMissions, hasData } = useMissionData(missions); + + // Estados combinados + const isLoading = isQuerying || isSpawning; + const error = queryError || spawnError; - // Inicializar misiones cuando se abre el modal + // 🎯 ORQUESTACIÓN PRINCIPAL + const initializeMissions = useCallback(async () => { + if (!playerAddress || isInitialized) return; + + try { + console.log("📡 Checking for existing missions..."); + const existingMissions = await fetchTodayMissions(playerAddress); + + if (existingMissions.length > 0) { + console.log(`✅ Found ${existingMissions.length} existing missions`); + setMissions(existingMissions); + } else { + console.log("🎲 No missions found, creating new ones..."); + const spawnSuccess = await spawnMissions(playerAddress); + + if (spawnSuccess) { + const newMissions = await fetchTodayMissions(playerAddress); + setMissions(newMissions); + } + } + + setIsInitialized(true); + } catch (error) { + console.error("❌ Error initializing missions:", error); + } + }, [playerAddress, isInitialized, fetchTodayMissions, spawnMissions]); + + // Ejecutar al abrir modal useEffect(() => { if (playerAddress) { - console.log("🚀 Modal opened, initializing missions for:", playerAddress); - //debugTimeCalculations(); initializeMissions(); } }, [playerAddress, initializeMissions]); - // Early return if no account connected + // Reset cuando cambia de usuario + useEffect(() => { + setMissions([]); + setIsInitialized(false); + }, [playerAddress]); + + // Early return if no account if (!account || !playerAddress) { return ( { const displayData = missionToDisplayData(mission); - // Check if this mission was claimed in this session displayData.claimed = claimedMissionIds.has(mission.id.toString()); return displayData; }); @@ -184,12 +202,7 @@ export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) { const handleClaimReward = (mission: MissionDisplayData) => { setClaimedMission(mission); setShowCelebration(true); - - // Mark as claimed in local state setClaimedMissionIds(prev => new Set(prev).add(mission.id)); - - console.log(`Claiming reward for mission: ${mission.id}, reward: ${mission.reward}`); - // TODO: Aquí irá la lógica de reward cuando implementemos ese feature }; const handleCloseCelebration = () => { @@ -198,10 +211,10 @@ export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) { }; // Loading states - const showLoading = isLoading || isSpawning; + const showLoading = isLoading; const loadingText = isSpawning ? "Creating your daily missions..." - : isLoading + : isQuerying ? "Loading missions..." : ""; @@ -214,7 +227,6 @@ export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) { exit={{ opacity: 0 }} onClick={onClose} > - {/* Container principal con flex column */} e.stopPropagation()} > - {/* Imagen del golem - ahora dentro del flujo normal */} - {/* Card del modal con las misiones */}
- {/* Título del modal */}

Daily Missions

Complete missions to earn rewards!

- {/* Estados de carga y error */} {showLoading && (
)} - {/* Lista de misiones */} {!showLoading && !error && hasData && (
{displayMissions.map((mission: MissionDisplayData, index: number) => ( @@ -299,17 +306,14 @@ export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) { transition={{ delay: 0.2 + index * 0.1 }} whileHover={{ scale: mission.completed ? 1 : 1.02 }} > - {/* Header: Dificultad, Recompensa y Estado */}
- {/* Etiqueta de dificultad */} {mission.difficulty} - {/* Recompensa */}
- {/* Indicador de completado */} {mission.completed && (
- {/* Título y descripción */}

- {/* Información adicional del mundo y golem requerido */}
🗺️ {mission.requiredWorld} 🧌 {mission.requiredGolem} Golem

- {/* Botón de reclamar */} {mission.completed && !mission.claimed && (
)} - {/* Overlay para misiones completadas */} {mission.completed && (
)} - {/* Estado cuando no hay misiones y no está cargando */} {!showLoading && !error && !hasData && (

@@ -414,7 +412,6 @@ export function DailyMissionsModal({ onClose }: DailyMissionsModalProps) {

)} - {/* Botón para cerrar */} { + + const todayMissions = useMemo(() => { + if (!Array.isArray(missions)) return []; + + return missions.filter(mission => { + try { + return isMissionFromToday(mission.created_at); + } catch (error) { + return false; + } + }); + }, [missions]); + + const pendingMissions = useMemo(() => { + return todayMissions.filter((mission) => { + try { + let statusVariant = "Pending"; + + if (mission.status && typeof mission.status.activeVariant === 'function') { + statusVariant = mission.status.activeVariant(); + } else if (mission.status && typeof mission.status === 'object') { + const statusObj = mission.status as any; + + if (statusObj.variant) { + if (statusObj.variant.Pending !== undefined) statusVariant = "Pending"; + else if (statusObj.variant.Completed !== undefined) statusVariant = "Completed"; + } + else if (statusObj.Pending !== undefined) statusVariant = "Pending"; + else if (statusObj.Completed !== undefined) statusVariant = "Completed"; + } + + return statusVariant === 'Pending'; + } catch (error) { + return true; // Assume pending on error + } + }); + }, [todayMissions]); + + const completedMissions = useMemo(() => { + return todayMissions.filter(mission => { + try { + let statusVariant = "Pending"; + + if (mission.status && typeof mission.status.activeVariant === 'function') { + statusVariant = mission.status.activeVariant(); + } else if (mission.status && typeof mission.status === 'object') { + const statusObj = mission.status as any; + + if (statusObj.variant) { + if (statusObj.variant.Completed !== undefined) statusVariant = "Completed"; + else if (statusObj.variant.Pending !== undefined) statusVariant = "Pending"; + } + else if (statusObj.Completed !== undefined) statusVariant = "Completed"; + else if (statusObj.Pending !== undefined) statusVariant = "Pending"; + } + + return statusVariant === 'Completed'; + } catch (error) { + return false; + } + }); + }, [todayMissions]); + + const hasData = todayMissions.length > 0; + + return { + todayMissions, + pendingMissions, + completedMissions, + hasData + }; +}; \ No newline at end of file diff --git a/client/src/dojo/hooks/useMissionQuery.tsx b/client/src/dojo/hooks/useMissionQuery.tsx new file mode 100644 index 0000000..4842e4f --- /dev/null +++ b/client/src/dojo/hooks/useMissionQuery.tsx @@ -0,0 +1,185 @@ +import { useState, useCallback } from "react"; +import { CairoCustomEnum } from "starknet"; +import { dojoConfig } from "../dojoConfig"; +import { Mission } from '../bindings'; +import { getCurrentDay } from '../../utils/TimeHelpers'; + +interface MissionEdge { + node: RawMissionNode; +} + +interface RawMissionNode { + id: string; + player_id: string; + target_coins: string; + required_world: any; + required_golem: any; + description: string; + status: any; + created_at: string; +} + +interface UseMissionQueryReturn { + missions: Mission[]; + isLoading: boolean; + error: string | null; + fetchTodayMissions: (playerAddress: string) => Promise; +} + +// Helper functions +const hexToNumber = (hexValue: string | number): number => { + if (typeof hexValue === 'number') return hexValue; + if (typeof hexValue === 'string' && hexValue.startsWith('0x')) { + return parseInt(hexValue, 16); + } + if (typeof hexValue === 'string') { + return parseInt(hexValue, 10); + } + return 0; +}; + +const createCairoEnum = (rawValue: any, enumMap: Record, defaultValue: string): CairoCustomEnum => { + if (!rawValue) { + return new CairoCustomEnum({ [defaultValue]: defaultValue }); + } + + if (typeof rawValue === 'string') { + const enumKey = enumMap[rawValue] ? rawValue : defaultValue; + return new CairoCustomEnum({ [enumKey]: enumKey }); + } + + if (typeof rawValue === 'object') { + if (rawValue.variant && typeof rawValue.variant === 'object') { + for (const [key] of Object.entries(rawValue.variant)) { + if (enumMap[key]) { + return new CairoCustomEnum({ [key]: key }); + } + } + } + + for (const [key] of Object.entries(rawValue)) { + if (enumMap[key]) { + return new CairoCustomEnum({ [key]: key }); + } + } + } + + return new CairoCustomEnum({ [defaultValue]: defaultValue }); +}; + +const toriiNodeToMission = (rawNode: RawMissionNode): Mission => { + const worldEnumMap = { Volcano: "Volcano", Glacier: "Glacier", Forest: "Forest" }; + const golemEnumMap = { Ice: "Ice", Stone: "Stone", Fire: "Fire" }; + const statusEnumMap = { Completed: "Completed", Pending: "Pending" }; + + const required_world = createCairoEnum(rawNode.required_world, worldEnumMap, "Forest"); + const required_golem = createCairoEnum(rawNode.required_golem, golemEnumMap, "Fire"); + const status = createCairoEnum(rawNode.status, statusEnumMap, "Pending"); + + const mission: Mission = { + id: hexToNumber(rawNode.id), + player_id: rawNode.player_id, + target_coins: hexToNumber(rawNode.target_coins), + required_world, + required_golem, + description: rawNode.description, + status, + created_at: hexToNumber(rawNode.created_at) + }; + + try { + mission.status.activeVariant(); + } catch (error) { + let statusKey = "Pending"; + + if (mission.status && typeof mission.status === 'object') { + const statusObj = mission.status as any; + + if (statusObj.variant) { + if (statusObj.variant.Pending !== undefined) statusKey = "Pending"; + else if (statusObj.variant.Completed !== undefined) statusKey = "Completed"; + } + else if (statusObj.Pending !== undefined) statusKey = "Pending"; + else if (statusObj.Completed !== undefined) statusKey = "Completed"; + } + + mission.status = new CairoCustomEnum({ [statusKey]: statusKey }); + } + + return mission; +}; + +export const useMissionQuery = (): UseMissionQueryReturn => { + const [missions, setMissions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchTodayMissions = useCallback(async (playerAddress: string): Promise => { + setIsLoading(true); + setError(null); + + try { + const currentDay = getCurrentDay(); + + const response = await fetch(dojoConfig.toriiUrl + "/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: ` + query GetMissions($playerAddress: ContractAddress!, $dayTimestamp: u32!) { + golemRunnerMissionModels( + where: { + player_id: $playerAddress, + created_at: $dayTimestamp + } + first: 10 + ) { + edges { + node { + id + player_id + target_coins + required_world + required_golem + description + status + created_at + } + } + } + } + `, + variables: { playerAddress, dayTimestamp: currentDay } + }), + }); + + const result = await response.json(); + + if (!result.data?.golemRunnerMissionModels?.edges) { + setMissions([]); + return []; + } + + const fetchedMissions = result.data.golemRunnerMissionModels.edges.map((edge: MissionEdge) => { + return toriiNodeToMission(edge.node); + }); + + setMissions(fetchedMissions); + return fetchedMissions; + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to fetch missions"; + setError(errorMessage); + throw err; + } finally { + setIsLoading(false); + } + }, []); + + return { + missions, + isLoading, + error, + fetchTodayMissions + }; +}; \ No newline at end of file diff --git a/client/src/dojo/hooks/useMissionSpawner.tsx b/client/src/dojo/hooks/useMissionSpawner.tsx new file mode 100644 index 0000000..fa74a1c --- /dev/null +++ b/client/src/dojo/hooks/useMissionSpawner.tsx @@ -0,0 +1,149 @@ +import { useState, useCallback } from "react"; +import { useAccount } from "@starknet-react/core"; +import { useDojoSDK } from "@dojoengine/sdk/react"; +import { Account, CairoCustomEnum } from "starknet"; +import { AIAgentService } from '../../services/aiAgent'; +import { + ElizaMissionData, + createFallbackMissions, + parseElizaResponse +} from '../../components/types/missionTypes'; + +interface UseMissionSpawnerReturn { + isSpawning: boolean; + error: string | null; + spawnMissions: (playerAddress: string) => Promise; +} + +const elizaDataToCairoEnums = (elizaData: ElizaMissionData) => { + const worldMap: Record = { + 'Forest': new CairoCustomEnum({ Forest: "Forest" }), + 'Volcano': new CairoCustomEnum({ Volcano: "Volcano" }), + 'Glacier': new CairoCustomEnum({ Glacier: "Glacier" }) + }; + + const golemMap: Record = { + 'Fire': new CairoCustomEnum({ Fire: "Fire" }), + 'Ice': new CairoCustomEnum({ Ice: "Ice" }), + 'Stone': new CairoCustomEnum({ Stone: "Stone" }) + }; + + return { + target_coins: elizaData.target_coins, + required_world: worldMap[elizaData.required_world] || worldMap['Forest'], + required_golem: golemMap[elizaData.required_golem] || golemMap['Fire'], + description: elizaData.description + }; +}; + +export const useMissionSpawner = (): UseMissionSpawnerReturn => { + const { client } = useDojoSDK(); + const { account } = useAccount(); + const [isSpawning, setIsSpawning] = useState(false); + const [error, setError] = useState(null); + + const spawnMissions = useCallback(async (): Promise => { + if (!account) { + setError("No account connected"); + return false; + } + + setIsSpawning(true); + setError(null); + + try { + // Generate 3 missions from Eliza + const elizaMissions: ElizaMissionData[] = []; + const fallbackMissions = createFallbackMissions(); + + for (let i = 0; i < 3; i++) { + try { + console.log(`🤖 Requesting mission ${i + 1} from Eliza...`); + + // Just a small delay to avoid rate limiting + if (i > 0) { + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + const elizaResponse = await AIAgentService.getDailyMission(); + const elizaData = parseElizaResponse(elizaResponse); + + if (elizaData) { + elizaMissions.push(elizaData); + console.log(`✅ Mission ${i + 1} from Eliza:`, elizaData); + } else { + const fallback = fallbackMissions[i] || fallbackMissions[0]; + elizaMissions.push(fallback); + console.log(`⚠️ Mission ${i + 1} parse failed, using fallback:`, fallback); + } + + } catch (error) { + console.error(`❌ Error getting mission ${i + 1} from Eliza:`, error); + const fallback = fallbackMissions[i] || fallbackMissions[0]; + elizaMissions.push(fallback); + } + } + + // Write missions into the Dojo Contracts + const results = []; + + for (let i = 0; i < elizaMissions.length; i++) { + const elizaData = elizaMissions[i]; + try { + const cairoData = elizaDataToCairoEnums(elizaData); + + const tx = await client.game.createMission( + account as Account, + cairoData.target_coins, + cairoData.required_world, + cairoData.required_golem, + cairoData.description + ); + + if (tx && tx.code === "SUCCESS") { + console.log(`✅ Mission ${i + 1} created successfully in dojo`); + console.log("Transaction details:", tx); + results.push({ success: true }); + } else { + results.push({ success: false, error: `Transaction failed: ${tx?.code}` }); + } + + // Small delay between transactions + if (i < elizaMissions.length - 1) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + } catch (error) { + results.push({ + success: false, + error: error instanceof Error ? error.message : "Unknown error" + }); + } + } + + const successful = results.filter(result => result.success).length; + + if (successful === 0) { + throw new Error("All mission creation transactions failed"); + } + + // Wait for blockchain to process + await new Promise(resolve => setTimeout(resolve, 8000)); + + return true; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Failed to spawn missions"; + setError(errorMessage); + return false; + } finally { + setIsSpawning(false); + } + }, [account, client.game]); + + return { + isSpawning, + error, + spawnMissions + }; +}; \ No newline at end of file diff --git a/client/src/dojo/hooks/useMissions.tsx b/client/src/dojo/hooks/useMissions.tsx deleted file mode 100644 index f51545c..0000000 --- a/client/src/dojo/hooks/useMissions.tsx +++ /dev/null @@ -1,561 +0,0 @@ -import { useState, useCallback, useMemo, useEffect } from "react"; -import { useAccount } from "@starknet-react/core"; -import { addAddressPadding, CairoCustomEnum } from "starknet"; -import { useDojoSDK } from "@dojoengine/sdk/react"; -import { Account } from "starknet"; -import { dojoConfig } from "../dojoConfig"; -import { Mission } from '../bindings'; -import useAppStore from '../../zustand/store'; -import { AIAgentService } from '../../services/aiAgent'; -import { - ElizaMissionData, - createFallbackMissions, - parseElizaResponse -} from '../../components/types/missionTypes'; -import { - getCurrentDay, - isMissionCacheStale, - isMissionFromToday -} from '../../utils/TimeHelpers'; - -// Types -interface MissionEdge { - node: RawMissionNode; -} - -interface RawMissionNode { - id: string; - player_id: string; - target_coins: string; - required_world: any; - required_golem: any; - description: string; - status: any; - created_at: string; -} - -interface UseMissionsInitReturn { - // Data - todayMissions: Mission[]; - pendingMissions: Mission[]; - completedMissions: Mission[]; - - // States - isLoading: boolean; - isSpawning: boolean; - error: string | null; - hasData: boolean; - - // Actions - initializeMissions: () => Promise; - refetchMissions: () => Promise; -} - -// Constants -const TORII_URL = dojoConfig.toriiUrl + "/graphql"; -const MISSIONS_QUERY = ` - query GetMissions($playerAddress: ContractAddress!, $dayTimestamp: u32!) { - golemRunnerMissionModels( - where: { - player_id: $playerAddress, - created_at: $dayTimestamp - } - first: 10 - ) { - edges { - node { - id - player_id - target_coins - required_world - required_golem - description - status - created_at - } - } - totalCount - } - } -`; - -// Helper functions -const hexToNumber = (hexValue: string | number): number => { - if (typeof hexValue === 'number') return hexValue; - if (typeof hexValue === 'string' && hexValue.startsWith('0x')) { - return parseInt(hexValue, 16); - } - if (typeof hexValue === 'string') { - return parseInt(hexValue, 10); - } - return 0; -}; - -/** - * Helper function to safely create CairoCustomEnum - */ -const createCairoEnum = (rawValue: any, enumMap: Record, defaultValue: string): CairoCustomEnum => { - // Handle null/undefined - if (!rawValue) { - return new CairoCustomEnum({ [defaultValue]: defaultValue }); - } - - // If it's already a string - if (typeof rawValue === 'string') { - const enumKey = enumMap[rawValue] ? rawValue : defaultValue; - return new CairoCustomEnum({ [enumKey]: enumKey }); - } - - // Handle Torii object format {Pending: {}} or {variant: {Pending: 'Pending'}} - if (typeof rawValue === 'object') { - // Check if it has variant property first - if (rawValue.variant && typeof rawValue.variant === 'object') { - for (const [key, value] of Object.entries(rawValue.variant)) { - if (enumMap[key]) { - return new CairoCustomEnum({ [key]: key }); - } - } - } - - // Check direct object format {Pending: {}} - for (const [key, value] of Object.entries(rawValue)) { - if (enumMap[key]) { - return new CairoCustomEnum({ [key]: key }); - } - } - } - - // Fallback to default - return new CairoCustomEnum({ [defaultValue]: defaultValue }); -}; - -/** - * Converts raw Torii response to Mission binding format - */ -const toriiNodeToMission = (rawNode: RawMissionNode): Mission => { - // Enum maps - const worldEnumMap = { Volcano: "Volcano", Glacier: "Glacier", Forest: "Forest" }; - const golemEnumMap = { Ice: "Ice", Stone: "Stone", Fire: "Fire" }; - const statusEnumMap = { Completed: "Completed", Pending: "Pending" }; - - // Create enums safely - const required_world = createCairoEnum(rawNode.required_world, worldEnumMap, "Forest"); - const required_golem = createCairoEnum(rawNode.required_golem, golemEnumMap, "Fire"); - const status = createCairoEnum(rawNode.status, statusEnumMap, "Pending"); - - const mission: Mission = { - id: hexToNumber(rawNode.id), - player_id: rawNode.player_id, - target_coins: hexToNumber(rawNode.target_coins), - required_world, - required_golem, - description: rawNode.description, - status, - created_at: hexToNumber(rawNode.created_at) - }; - - // Test and patch status if needed - try { - mission.status.activeVariant(); - } catch (error) { - // If activeVariant fails, create a working enum manually - let statusKey = "Pending"; // default - - if (mission.status && typeof mission.status === 'object') { - const statusObj = mission.status as any; - - if (statusObj.variant) { - if (statusObj.variant.Pending !== undefined) statusKey = "Pending"; - else if (statusObj.variant.Completed !== undefined) statusKey = "Completed"; - } - else if (statusObj.Pending !== undefined) statusKey = "Pending"; - else if (statusObj.Completed !== undefined) statusKey = "Completed"; - } - - mission.status = new CairoCustomEnum({ [statusKey]: statusKey }); - } - - return mission; -}; - -/** - * Fetches missions from Torii GraphQL - */ -const fetchMissionsFromTorii = async (playerAddress: string): Promise => { - try { - const currentDay = getCurrentDay(); - - const response = await fetch(TORII_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: MISSIONS_QUERY, - variables: { - playerAddress, - dayTimestamp: currentDay - } - }), - }); - - const result = await response.json(); - - if (!result.data?.golemRunnerMissionModels?.edges) { - return []; - } - - const missions = result.data.golemRunnerMissionModels.edges.map((edge: MissionEdge) => { - return toriiNodeToMission(edge.node); - }); - - return missions; - } catch (error) { - console.error("❌ Error fetching missions from Torii:", error); - throw error; - } -}; - -/** - * Converts ElizaMissionData to Cairo enums for contract - */ -const elizaDataToCairoEnums = (elizaData: ElizaMissionData) => { - const worldMap: Record = { - 'Forest': new CairoCustomEnum({ Forest: "Forest" }), - 'Volcano': new CairoCustomEnum({ Volcano: "Volcano" }), - 'Glacier': new CairoCustomEnum({ Glacier: "Glacier" }) - }; - - const golemMap: Record = { - 'Fire': new CairoCustomEnum({ Fire: "Fire" }), - 'Ice': new CairoCustomEnum({ Ice: "Ice" }), - 'Stone': new CairoCustomEnum({ Stone: "Stone" }) - }; - - return { - target_coins: elizaData.target_coins, - required_world: worldMap[elizaData.required_world] || worldMap['Forest'], - required_golem: golemMap[elizaData.required_golem] || golemMap['Fire'], - description: elizaData.description - }; -}; - -/** - * Main hook - Solo para inicialización de misiones - */ -export const useMissionsInit = (): UseMissionsInitReturn => { - const { client } = useDojoSDK(); - const { account } = useAccount(); - - // Zustand store - const { - missions, - lastMissionFetch, - isMissionsLoading, - setMissions, - setMissionsLoading, - setMissionsError - } = useAppStore(); - - // Local state - const [isSpawning, setIsSpawning] = useState(false); - const [spawnAttempts, setSpawnAttempts] = useState(0); - const [lastSpawnTime, setLastSpawnTime] = useState(0); - - const MAX_SPAWN_ATTEMPTS = 1; // Solo un intento por sesión - const MIN_SPAWN_INTERVAL = 30000; // 30 segundos entre intentos - - // Memoize user address - const userAddress = useMemo(() => - account ? addAddressPadding(account.address) : null, - [account] - ); - - // Memoized derived data with defensive programming - const todayMissions = useMemo(() => { - if (!Array.isArray(missions)) { - return []; - } - - return missions.filter(mission => { - try { - return isMissionFromToday(mission.created_at); - } catch (error) { - return false; - } - }); - }, [missions]); - - const pendingMissions = useMemo(() => { - if (!Array.isArray(todayMissions)) { - return []; - } - - return todayMissions.filter((mission) => { - try { - let statusVariant = "Pending"; // default assumption - - if (mission.status && typeof mission.status.activeVariant === 'function') { - statusVariant = mission.status.activeVariant(); - } else if (mission.status && typeof mission.status === 'object') { - const statusObj = mission.status as any; - - if (statusObj.variant) { - if (statusObj.variant.Pending !== undefined) statusVariant = "Pending"; - else if (statusObj.variant.Completed !== undefined) statusVariant = "Completed"; - } - else if (statusObj.Pending !== undefined) statusVariant = "Pending"; - else if (statusObj.Completed !== undefined) statusVariant = "Completed"; - } - - return statusVariant === 'Pending'; - - } catch (error) { - return true; // Assume pending on error - } - }); - }, [todayMissions]); - - const completedMissions = useMemo(() => { - try { - return todayMissions.filter(mission => { - try { - let statusVariant = "Pending"; - - if (mission.status && typeof mission.status.activeVariant === 'function') { - statusVariant = mission.status.activeVariant(); - } else if (mission.status && typeof mission.status === 'object') { - const statusObj = mission.status as any; - - if (statusObj.variant) { - if (statusObj.variant.Completed !== undefined) statusVariant = "Completed"; - else if (statusObj.variant.Pending !== undefined) statusVariant = "Pending"; - } - else if (statusObj.Completed !== undefined) statusVariant = "Completed"; - else if (statusObj.Pending !== undefined) statusVariant = "Pending"; - } - - return statusVariant === 'Completed'; - } catch (error) { - return false; - } - }); - } catch (error) { - return []; - } - }, [todayMissions]); - - const hasData = todayMissions.length > 0; - - /** - * Check if we can spawn (rate limiting) - */ - const canSpawn = useCallback((): boolean => { - const now = Date.now(); - - if (spawnAttempts >= MAX_SPAWN_ATTEMPTS) { - return false; - } - - if (lastSpawnTime && (now - lastSpawnTime) < MIN_SPAWN_INTERVAL) { - return false; - } - - return true; - }, [spawnAttempts, lastSpawnTime]); - - /** - * Fetch missions from Torii and update store - */ - const refetchMissions = useCallback(async (): Promise => { - if (!userAddress) { - return; - } - - setMissionsLoading(true); - setMissionsError(null); - - try { - const fetchedMissions = await fetchMissionsFromTorii(userAddress); - setMissions(fetchedMissions); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Failed to fetch missions"; - setMissionsError(errorMessage); - throw err; - } finally { - setMissionsLoading(false); - } - }, [userAddress, setMissions, setMissionsLoading, setMissionsError]); - - /** - * Generate missions with error handling and tracking - */ - const spawnNewMissions = useCallback(async (): Promise => { - if (!userAddress || !account) { - return false; - } - - if (!canSpawn()) { - setMissionsError("Mission generation is temporarily limited. Please try again later."); - return false; - } - - setIsSpawning(true); - setMissionsError(null); - - // Update tracking - setSpawnAttempts(prev => prev + 1); - setLastSpawnTime(Date.now()); - - try { - // Generate 3 missions from Eliza - const elizaMissions: ElizaMissionData[] = []; - const fallbackMissions = createFallbackMissions(); - - for (let i = 0; i < 3; i++) { - try { - const elizaResponse = await AIAgentService.getDailyMission(userAddress); - const elizaData = parseElizaResponse(elizaResponse); - - if (elizaData) { - elizaMissions.push(elizaData); - } else { - elizaMissions.push(fallbackMissions[i] || fallbackMissions[0]); - } - } catch (error) { - elizaMissions.push(fallbackMissions[i] || fallbackMissions[0]); - } - } - - // Create missions in contract (sequential transactions) - const results = []; - - for (let i = 0; i < elizaMissions.length; i++) { - const elizaData = elizaMissions[i]; - try { - const cairoData = elizaDataToCairoEnums(elizaData); - - const tx = await client.game.createMission( - account as Account, - cairoData.target_coins, - cairoData.required_world, - cairoData.required_golem, - cairoData.description - ); - - if (tx && tx.code === "SUCCESS") { - results.push({ success: true, mission: elizaData, tx }); - } else { - results.push({ success: false, mission: elizaData, error: `Transaction failed with code: ${tx?.code}` }); - } - - // Small delay between transactions to avoid nonce issues - if (i < elizaMissions.length - 1) { - await new Promise(resolve => setTimeout(resolve, 1000)); - } - - } catch (error) { - results.push({ - success: false, - mission: elizaData, - error: error instanceof Error ? error.message : "Unknown error" - }); - } - } - - // Check results - const successful = results.filter(result => result.success).length; - - if (successful === 0) { - throw new Error("All mission creation transactions failed"); - } - - // Wait for blockchain to process - await new Promise(resolve => setTimeout(resolve, 8000)); - - // Refetch missions from Torii - await refetchMissions(); - - return true; - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Failed to spawn missions"; - setMissionsError(errorMessage); - return false; - } finally { - setIsSpawning(false); - } - }, [userAddress, account, client.game, refetchMissions, setMissionsError, canSpawn]); - - /** - * Main initialization function - */ - const initializeMissions = useCallback(async (): Promise => { - if (!userAddress) { - return false; - } - - // 1. Check if cache is fresh and has today's missions - if (!isMissionCacheStale(lastMissionFetch) && todayMissions.length > 0) { - return true; - } - - // 2. Always fetch from Torii first (fresh data) - try { - await refetchMissions(); - - // 3. After refetch, check if we have today's missions - const currentTodayMissions = missions.filter(mission => isMissionFromToday(mission.created_at)); - - if (currentTodayMissions.length > 0) { - return true; - } - - // 4. Only spawn if we can and have no missions - if (canSpawn()) { - return await spawnNewMissions(); - } else { - setMissionsError("No daily missions available. Generation is temporarily limited."); - return false; - } - - } catch (error) { - // If fetch fails and we can spawn, try to create new missions - if (canSpawn()) { - return await spawnNewMissions(); - } else { - setMissionsError("Failed to load missions and generation is rate-limited."); - return false; - } - } - }, [userAddress, lastMissionFetch, todayMissions.length, missions, refetchMissions, spawnNewMissions, canSpawn, setMissionsError]); - - // 🛠️ ANTI-CICLO: Solo reset spawn tracking cuando cambia userAddress - useEffect(() => { - setSpawnAttempts(0); - setLastSpawnTime(0); - }, [userAddress]); - - // 🛠️ ANTI-CICLO: Solo clear missions cuando userAddress cambia y existe - useEffect(() => { - if (userAddress) { - useAppStore.getState().clearMissions(); - setMissionsError(null); - } - }, [userAddress, setMissionsError]); - - return { - // Data - todayMissions, - pendingMissions, - completedMissions, - - // States - isLoading: isMissionsLoading, - isSpawning, - error: useAppStore.getState().missionsError, - hasData, - - // Actions - initializeMissions, - refetchMissions - }; -}; \ No newline at end of file diff --git a/client/src/services/aiAgent.ts b/client/src/services/aiAgent.ts index cffdbab..5a3fdac 100644 --- a/client/src/services/aiAgent.ts +++ b/client/src/services/aiAgent.ts @@ -1,5 +1,3 @@ -import { db } from './db'; - const ELIZA_URL = import.meta.env.VITE_ELIZA_URL; interface AIMessageResponse { @@ -11,14 +9,13 @@ interface AIMessageResponse { export class AIAgentService { private static async fetchDailyMission(): Promise { try { - const response = await fetch(ELIZA_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - text: "Give me a daily mission for my golem", // TODO: Provide dinamically the golem name and map available + text: "Give me a daily mission for my golem", userId: "user", userName: "User" }) @@ -40,54 +37,11 @@ export class AIAgentService { } catch (error) { console.error('Error fetching daily mission:', error); - return 'Default mission: Complete 3 battles today!'; + throw error; } } - static async getDailyMission(playerAddress: string | undefined | null): Promise { - - if (!playerAddress || typeof playerAddress !== 'string') { - return 'Default mission: Complete 3 battles today!'; - } - - try { - const cachedMission = await db.dailyMissions - .where('playerAddress') - .equals(playerAddress) - .last(); - - if (cachedMission) { - const now = Date.now(); - const missionAge = now - cachedMission.timestamp; - const twentyFourHours = 24 * 60 * 60 * 1000; - - if (missionAge < twentyFourHours) { - return cachedMission.mission; - } - } - - const newMission = await this.fetchDailyMission(); - - if (!newMission) { - throw new Error('Failed to get new mission'); - } - - // First try to delete any existing mission for the player - await db.dailyMissions - .where('playerAddress') - .equals(playerAddress) - .delete(); - - // Then add the new mission - await db.dailyMissions.add({ - playerAddress, - mission: newMission, - timestamp: Date.now() - }); - return newMission; - } catch (error) { - console.error('Error in getDailyMission:', error); - return 'Default mission: Complete 3 battles today!'; - } + static async getDailyMission(): Promise { + return await this.fetchDailyMission(); } -} \ No newline at end of file +} \ No newline at end of file