diff --git a/client/src/components/types/missionTypes.ts b/client/src/components/types/missionTypes.ts new file mode 100644 index 0000000..d2ea909 --- /dev/null +++ b/client/src/components/types/missionTypes.ts @@ -0,0 +1,70 @@ +/** + * Super simple types for mission management + * Zustand uses the full Mission bindings, but these are for working data + */ + +/** + * Simple mission data that Eliza returns (no complex types) + */ +export interface ElizaMissionData { + target_coins: number; + required_world: 'Forest' | 'Volcano' | 'Glacier'; + required_golem: 'Fire' | 'Ice' | 'Stone'; + description: string; +} + +/** + * Simple display data for the UI (converted from Mission bindings) + */ +export interface MissionDisplayData { + id: number; + title: string; + description: string; + difficulty: 'Easy' | 'Mid' | 'Hard'; + reward: number; + requiredWorld: string; + requiredGolem: string; + completed: boolean; + claimed?: boolean; +} + +/** + * Creates fallback missions if Eliza fails + */ +export function createFallbackMissions(): ElizaMissionData[] { + return [ + { + target_coins: 300, + required_world: 'Forest', + required_golem: 'Stone', + description: 'Collect 300 coins in the mystical forest with your stone golem' + }, + { + target_coins: 500, + required_world: 'Volcano', + required_golem: 'Fire', + description: 'Gather 500 coins from the volcanic realm using your fire golem' + }, + { + target_coins: 750, + required_world: 'Glacier', + required_golem: 'Ice', + description: 'Obtain 750 coins in the frozen wastes with your ice golem' + } + ]; +} + +/** + * Validates if Eliza response has the correct structure + */ +export function isValidElizaMissionData(data: any): data is ElizaMissionData { + return ( + typeof data === 'object' && + typeof data.target_coins === 'number' && + ['Forest', 'Volcano', 'Glacier'].includes(data.required_world) && + ['Fire', 'Ice', 'Stone'].includes(data.required_golem) && + typeof data.description === 'string' && + data.target_coins > 0 && + data.description.length > 0 + ); +} \ No newline at end of file diff --git a/client/src/utils/TimeHelpers.ts b/client/src/utils/TimeHelpers.ts new file mode 100644 index 0000000..268fbf9 --- /dev/null +++ b/client/src/utils/TimeHelpers.ts @@ -0,0 +1,125 @@ +/** + * Time utilities for mission management + * Mirrors the Cairo timestamp utilities from the contract + */ + +// Constants matching Cairo implementation +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 + * @param timestamp Unix timestamp in seconds + * @returns Day number since Unix epoch + */ +export function unixTimestampToDay(timestamp: number): number { + return Math.floor(timestamp / SECONDS_PER_DAY); +} + +/** + * 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 + */ +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 +} + +/** + * Gets the current day number + * @returns Current day number since Unix epoch + */ +export function getCurrentDay(): number { + return unixTimestampToDay(getCurrentDayTimestamp()); +} + +/** + * Checks if two timestamps are from different days + * @param timestamp1 First timestamp in milliseconds (JS Date.now()) + * @param timestamp2 Second timestamp in milliseconds (JS Date.now()) + * @returns true if timestamps are from different days + */ +export function isDifferentDay(timestamp1: number, timestamp2: number): boolean { + const day1 = unixTimestampToDay(Math.floor(timestamp1 / 1000)); + const day2 = unixTimestampToDay(Math.floor(timestamp2 / 1000)); + return day1 !== day2; +} + +/** + * Checks if a mission was created today + * @param missionCreatedAt Mission's created_at timestamp (from contract, in seconds) + * @returns true if mission was created today + */ +export function isMissionFromToday(missionCreatedAt: number): boolean { + const missionDay = unixTimestampToDay(missionCreatedAt); + const currentDay = getCurrentDay(); + return missionDay === currentDay; +} + +/** + * Checks if the missions cache is stale (from a different day) + * @param lastFetchTimestamp Last time missions were fetched (JS timestamp in ms) + * @returns true if cache is stale and needs refresh + */ +export function isMissionCacheStale(lastFetchTimestamp: number | null): boolean { + if (!lastFetchTimestamp) return true; + + return isDifferentDay(lastFetchTimestamp, Date.now()); +} + +/** + * Gets a human-readable time until end of day + * @returns String like "5h 23m" until midnight + */ +export function getTimeUntilMidnight(): string { + const now = new Date(); + const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + const diff = midnight.getTime() - now.getTime(); + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; +} + +/** + * Formats a timestamp for debug/logging purposes + * @param timestamp Unix timestamp in seconds + * @returns Formatted date string + */ +export function formatTimestamp(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleString(); +} + +/** + * Gets the timestamp for a specific day offset from today + * @param daysOffset Days to add/subtract from today (negative for past days) + * @returns Unix timestamp for start of that day in seconds + */ +export function getDayTimestamp(daysOffset: number = 0): number { + const now = new Date(); + const targetDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + daysOffset); + return Math.floor(targetDate.getTime() / 1000); +} + +// Debug utility to validate our time calculations +export function debugTimeUtils() { + const now = Date.now(); + const currentDayTimestamp = getCurrentDayTimestamp(); + const currentDay = getCurrentDay(); + + console.log('=== Time Utils Debug ==='); + console.log('Current JS time (ms):', now); + console.log('Current day timestamp (s):', currentDayTimestamp); + console.log('Current day number:', currentDay); + console.log('Formatted current day:', formatTimestamp(currentDayTimestamp)); + console.log('Time until midnight:', getTimeUntilMidnight()); + console.log('Yesterday timestamp:', getDayTimestamp(-1)); + console.log('Tomorrow timestamp:', getDayTimestamp(1)); +} \ No newline at end of file diff --git a/client/src/zustand/store.ts b/client/src/zustand/store.ts index 4fc8545..ad71b5e 100644 --- a/client/src/zustand/store.ts +++ b/client/src/zustand/store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { Player, Golem, World, Ranking } from '../dojo/bindings'; +import { Player, Golem, World, Ranking, Mission } from '../dojo/bindings'; // Define application state interface interface AppState { @@ -12,6 +12,12 @@ interface AppState { worlds: World[]; rankings: Ranking[]; + // Missions data + missions: Mission[]; + lastMissionFetch: number | null; // Timestamp of last fetch + isMissionsLoading: boolean; + missionsError: string | null; + // UI state isLoading: boolean; error: string | null; @@ -46,6 +52,15 @@ interface AppActions { setRankings: (rankings: Ranking[]) => void; updateRanking: (ranking: Ranking) => void; + // Mission actions - NEW + setMissions: (missions: Mission[]) => void; + addMission: (mission: Mission) => void; + updateMissionStatus: (missionId: number, status: 'Pending' | 'Completed') => void; + setMissionsLoading: (loading: boolean) => void; + setMissionsError: (error: string | null) => void; + setLastMissionFetch: (timestamp: number) => void; + clearMissions: () => void; + // UI actions setLoading: (loading: boolean) => void; setError: (error: string | null) => void; @@ -68,6 +83,10 @@ const initialState: AppState = { golems: [], worlds: [], rankings: [], + missions: [], + lastMissionFetch: null, + isMissionsLoading: false, + missionsError: null, isLoading: false, error: null, currentGolem: null, @@ -78,7 +97,7 @@ const initialState: AppState = { // Create the store const useAppStore = create()( persist( - (set, _get) => ({ + (set) => ({ // Initial state ...initialState, @@ -150,6 +169,42 @@ const useAppStore = create()( } }), + // Mission actions + setMissions: (missions) => set({ + missions, + lastMissionFetch: Date.now(), + missionsError: null + }), + + addMission: (mission) => set((state) => ({ + missions: [...state.missions, mission] + })), + + updateMissionStatus: (missionId, status) => set((state) => ({ + missions: state.missions.map(mission => + mission.id === missionId + ? { + ...mission, + status: status === 'Completed' + ? { activeVariant: 'Completed', Completed: 'Completed' } as any + : { activeVariant: 'Pending', Pending: 'Pending' } as any + } + : mission + ) + })), + + setMissionsLoading: (isMissionsLoading) => set({ isMissionsLoading }), + + setMissionsError: (missionsError) => set({ missionsError }), + + setLastMissionFetch: (lastMissionFetch) => set({ lastMissionFetch }), + + clearMissions: () => set({ + missions: [], + lastMissionFetch: null, + missionsError: null + }), + // UI actions setLoading: (isLoading) => set({ isLoading }), setError: (error) => set({ error }), @@ -175,6 +230,8 @@ const useAppStore = create()( worlds: state.worlds, currentGolem: state.currentGolem, currentWorld: state.currentWorld, + missions: state.missions, + lastMissionFetch: state.lastMissionFetch, // persist last fetch time }), } )