diff --git a/src/blobbi/actions/hooks/useBlobbiDirectAction.ts b/src/blobbi/actions/hooks/useBlobbiDirectAction.ts index 06e2149d..e96df240 100644 --- a/src/blobbi/actions/hooks/useBlobbiDirectAction.ts +++ b/src/blobbi/actions/hooks/useBlobbiDirectAction.ts @@ -18,8 +18,9 @@ import { DIRECT_ACTION_METADATA, type DirectAction, } from '../lib/blobbi-action-utils'; -import { trackMultipleDailyMissionActions, trackEvolutionMissionTally } from '../lib/daily-mission-tracker'; +import { trackMultipleDailyMissionActions, trackEvolutionMissionTally, readEvolutionFromStorage } from '../lib/daily-mission-tracker'; import type { DailyMissionAction } from '../lib/daily-missions'; +import { serializeEvolutionContent } from '@/blobbi/core/lib/missions'; import { getStreakTagUpdates } from '../lib/blobbi-streak'; import { calculateActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp'; @@ -150,9 +151,20 @@ export function useBlobbiDirectAction({ const progressionState = canonical.companion.progressionState; const updatedTags = canonical.allTags; if (progressionState === 'incubating' || progressionState === 'evolving') { - trackEvolutionMissionTally('interactions', 1, user.pubkey); + trackEvolutionMissionTally('interactions', 1, user.pubkey, canonical.companion.d); } + // ─── Build content with latest evolution state ─── + // Read the updated evolution from session store so the publish carries + // the latest progress, instead of relying on the debounce hook. + let content = canonical.content; + if (progressionState === 'incubating' || progressionState === 'evolving') { + const evo = readEvolutionFromStorage(user.pubkey, canonical.companion.d); + if (evo && evo.length > 0) { + content = serializeEvolutionContent(canonical.content, evo); + } + } + // Get streak updates (will only update if needed based on day) const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {}; @@ -172,7 +184,7 @@ export function useBlobbiDirectAction({ const blobbiEvent = await publishEvent({ kind: KIND_BLOBBI_STATE, - content: canonical.content, + content, tags: blobbiTags, }); diff --git a/src/blobbi/actions/hooks/useBlobbiIncubation.ts b/src/blobbi/actions/hooks/useBlobbiIncubation.ts index 5775546e..419b1902 100644 --- a/src/blobbi/actions/hooks/useBlobbiIncubation.ts +++ b/src/blobbi/actions/hooks/useBlobbiIncubation.ts @@ -27,10 +27,11 @@ import { updateBlobbiTags, } from '@/blobbi/core/lib/blobbi'; import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay'; +import { serializeEvolutionContent } from '@/blobbi/core/lib/missions'; import { createHatchMissions, createEvolveMissions } from '../lib/evolution-missions'; import { - ensureSessionStore, - writeMissionsToStorage, + writeEvolutionToStorage, + clearEvolutionFromStorage, } from '../lib/daily-mission-tracker'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -206,15 +207,21 @@ export function useStartIncubation({ last_decay_at: nowStr, }); + // Clear evolution from the other Blobbi's content + const otherContent = serializeEvolutionContent(otherEvent.content, []); + // Publish the stop event for the other Blobbi const stopEvent = await publishEvent({ kind: KIND_BLOBBI_STATE, - content: otherEvent.content, + content: otherContent, tags: otherNewTags, }); // Update the cache for the stopped Blobbi updateCompanionEvent(stopEvent); + + // Clear evolution session store for the stopped Blobbi + clearEvolutionFromStorage(user.pubkey, stopOtherD); } } @@ -261,22 +268,22 @@ export function useStartIncubation({ last_decay_at: nowStr, }); + // ─── Build evolution content for 31124 ─── + const hatchMissions = createHatchMissions(); + const content = serializeEvolutionContent(canonical.content, hatchMissions); + // ─── Publish Event ─── const event = await publishEvent({ kind: KIND_BLOBBI_STATE, - content: canonical.content, + content, tags: newTags, }); updateCompanionEvent(event); - // ─── Populate evolution missions in session store ─── - const currentMissions = ensureSessionStore(user.pubkey); - writeMissionsToStorage( - { ...currentMissions, evolution: createHatchMissions() }, - user.pubkey, - ); - window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } })); + // ─── Populate evolution missions in session store (per-Blobbi) ─── + writeEvolutionToStorage(hatchMissions, user.pubkey, canonical.companion.d); + window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: canonical.companion.d } })); return { name: canonical.companion.name, @@ -424,22 +431,21 @@ export function useStopIncubation({ last_decay_at: nowStr, }); + // ─── Clear evolution from 31124 content ─── + const content = serializeEvolutionContent(canonical.content, []); + // ─── Publish Event ─── const event = await publishEvent({ kind: KIND_BLOBBI_STATE, - content: canonical.content, + content, tags: newTags, }); updateCompanionEvent(event); // ─── Clear evolution missions in session store ─── - const currentMissions = ensureSessionStore(user.pubkey); - writeMissionsToStorage( - { ...currentMissions, evolution: [] }, - user.pubkey, - ); - window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } })); + clearEvolutionFromStorage(user.pubkey, canonical.companion.d); + window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: canonical.companion.d } })); return { name: canonical.companion.name, @@ -557,22 +563,22 @@ export function useStartEvolution({ last_decay_at: nowStr, }); + // ─── Build evolution content for 31124 ─── + const evolveMissions = createEvolveMissions(); + const content = serializeEvolutionContent(canonical.content, evolveMissions); + // ─── Publish Event ─── const event = await publishEvent({ kind: KIND_BLOBBI_STATE, - content: canonical.content, + content, tags: newTags, }); updateCompanionEvent(event); - // ─── Populate evolution missions in session store ─── - const currentMissions = ensureSessionStore(user.pubkey); - writeMissionsToStorage( - { ...currentMissions, evolution: createEvolveMissions() }, - user.pubkey, - ); - window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } })); + // ─── Populate evolution missions in session store (per-Blobbi) ─── + writeEvolutionToStorage(evolveMissions, user.pubkey, canonical.companion.d); + window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: canonical.companion.d } })); return { name: canonical.companion.name, @@ -705,22 +711,21 @@ export function useStopEvolution({ last_decay_at: nowStr, }); + // ─── Clear evolution from 31124 content ─── + const content = serializeEvolutionContent(canonical.content, []); + // ─── Publish Event ─── const event = await publishEvent({ kind: KIND_BLOBBI_STATE, - content: canonical.content, + content, tags: newTags, }); updateCompanionEvent(event); // ─── Clear evolution missions in session store ─── - const currentMissions = ensureSessionStore(user.pubkey); - writeMissionsToStorage( - { ...currentMissions, evolution: [] }, - user.pubkey, - ); - window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } })); + clearEvolutionFromStorage(user.pubkey, canonical.companion.d); + window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: canonical.companion.d } })); return { name: canonical.companion.name, diff --git a/src/blobbi/actions/hooks/useBlobbiStageTransition.ts b/src/blobbi/actions/hooks/useBlobbiStageTransition.ts index f9b8107f..b91917d4 100644 --- a/src/blobbi/actions/hooks/useBlobbiStageTransition.ts +++ b/src/blobbi/actions/hooks/useBlobbiStageTransition.ts @@ -27,19 +27,22 @@ import { } from '@/blobbi/core/lib/blobbi'; import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay'; import { validateAndRepairBlobbiTags } from '@/blobbi/core/lib/blobbi-tag-schema'; +import { serializeEvolutionContent } from '@/blobbi/core/lib/missions'; +import { createEvolveMissions } from '../lib/evolution-missions'; +import { writeEvolutionToStorage, clearEvolutionFromStorage } from '../lib/daily-mission-tracker'; import { getStreakTagUpdates } from '../lib/blobbi-streak'; // ─── Content Helpers ────────────────────────────────────────────────────────── /** * Generate the content string for a Blobbi at a given stage. - * Format: "{name} is a {stage} Blobbi." - * - * Uses correct grammar: "an egg" vs "a baby/adult" + * Now stores JSON with an optional evolution array. + * Falls back to a descriptive JSON content when no evolution is active. */ -function generateBlobbiContent(name: string, stage: BlobbiStage): string { - const article = stage === 'egg' ? 'an' : 'a'; - return `${name} is ${article} ${stage} Blobbi.`; +function generateBlobbiContent(_name: string, _stage: BlobbiStage): string { + // Return empty JSON — evolution will be populated separately when needed. + // The old plain-text format ("Luna is an egg Blobbi.") is no longer used. + return JSON.stringify({}); } // ─── Types ──────────────────────────────────────────────────────────────────── @@ -209,9 +212,13 @@ export function useBlobbiHatch({ progression_started_at: nowStr, }); - // ─── Generate New Content for Baby Stage ─── - // CRITICAL: Content must reflect the new stage - const newContent = generateBlobbiContent(canonical.companion.name, 'baby'); + // ─── Write evolution missions into 31124 content ─── + // Baby auto-starts evolution, so seed the missions immediately. + const evolveMissions = createEvolveMissions(); + const newContent = serializeEvolutionContent( + generateBlobbiContent(canonical.companion.name, 'baby'), + evolveMissions, + ); // ─── Publish Event ─── const event = await publishEvent({ @@ -222,6 +229,12 @@ export function useBlobbiHatch({ updateCompanionEvent(event); + // ─── Seed evolution session store for immediate tally tracking ─── + if (user?.pubkey) { + writeEvolutionToStorage(evolveMissions, user.pubkey, canonical.companion.d); + window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: canonical.companion.d } })); + } + return { previousStage: 'egg', newStage: 'baby', @@ -360,9 +373,11 @@ export function useBlobbiEvolve({ progression_state: 'none', }); - // ─── Generate New Content for Adult Stage ─── - // CRITICAL: Content must reflect the new stage - const newContent = generateBlobbiContent(canonical.companion.name, 'adult'); + // ─── Clear evolution from 31124 content (progression complete) ─── + const newContent = serializeEvolutionContent( + generateBlobbiContent(canonical.companion.name, 'adult'), + [], + ); // ─── Publish Event ─── const event = await publishEvent({ @@ -373,6 +388,11 @@ export function useBlobbiEvolve({ updateCompanionEvent(event); + // ─── Clear evolution session store ─── + if (user?.pubkey) { + clearEvolutionFromStorage(user.pubkey, canonical.companion.d); + } + return { previousStage: 'baby', newStage: 'adult', diff --git a/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts b/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts index e7d6dbd7..83d16ea2 100644 --- a/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts +++ b/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts @@ -24,8 +24,9 @@ import { type InventoryAction, ACTION_METADATA, } from '../lib/blobbi-action-utils'; -import { trackMultipleDailyMissionActions, trackEvolutionMissionTally } from '../lib/daily-mission-tracker'; +import { trackMultipleDailyMissionActions, trackEvolutionMissionTally, readEvolutionFromStorage } from '../lib/daily-mission-tracker'; import type { DailyMissionAction } from '../lib/daily-missions'; +import { serializeEvolutionContent } from '@/blobbi/core/lib/missions'; import { getStreakTagUpdates } from '../lib/blobbi-streak'; import { calculateInventoryActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp'; @@ -244,9 +245,18 @@ export function useBlobbiUseInventoryItem({ const progressionState = canonical.companion.progressionState; const updatedTags = canonical.allTags; if (progressionState === 'incubating' || progressionState === 'evolving') { - trackEvolutionMissionTally('interactions', 1, user?.pubkey); + trackEvolutionMissionTally('interactions', 1, user?.pubkey, canonical.companion.d); } + // ─── Build content with latest evolution state ─── + let content = canonical.content; + if (progressionState === 'incubating' || progressionState === 'evolving') { + const evo = readEvolutionFromStorage(user?.pubkey, canonical.companion.d); + if (evo && evo.length > 0) { + content = serializeEvolutionContent(canonical.content, evo); + } + } + // Get streak updates (will only update if needed based on day) const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {}; @@ -265,7 +275,7 @@ export function useBlobbiUseInventoryItem({ const blobbiEvent = await publishEvent({ kind: KIND_BLOBBI_STATE, - content: canonical.content, + content, tags: blobbiTags, }); diff --git a/src/blobbi/actions/hooks/useDailyMissions.ts b/src/blobbi/actions/hooks/useDailyMissions.ts index 169ae7ac..143a9a4d 100644 --- a/src/blobbi/actions/hooks/useDailyMissions.ts +++ b/src/blobbi/actions/hooks/useDailyMissions.ts @@ -1,7 +1,7 @@ /** * useDailyMissions - Hook for reading daily mission state * - * Provides reactive access to the current day's missions. + * Provides reactive access to the current day's daily missions. * Progress tracking is done via the tracker module (non-React). * Completion is implicit (derived from count/events vs target). * XP is awarded automatically when missions complete. @@ -10,6 +10,9 @@ * switch, hydrates from kind 11125 content JSON if the session store * is empty. Completed missions are persisted by `useAwardDailyXp`; * intermediate progress resets on page refresh. + * + * NOTE: Evolution missions are NOT managed here. They live on kind 31124 + * (per-Blobbi) and are handled by the evolution session store. */ import { useMemo, useEffect, useState, useCallback, useRef } from 'react'; @@ -34,9 +37,9 @@ import { } from '../lib/daily-missions'; import { - readMissionsFromStorage, - writeMissionsToStorage, - hydrateFromPersisted, + readDailyFromStorage, + writeDailyToStorage, + hydrateDailyFromPersisted, } from '../lib/daily-mission-tracker'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -66,7 +69,7 @@ export interface UseDailyMissionsOptions { /** * Raw content string from the kind 11125 profile event. * Pass `profile.content` here. The hook parses it to extract - * persisted missions and hydrates the session store on first load. + * persisted daily missions and hydrates the session store on first load. */ profileContent?: string; } @@ -113,32 +116,23 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail if (hydratedRef.current === pubkey) return; // already hydrated this session // Check if session store already has data for this pubkey - const existing = readMissionsFromStorage(pubkey); + const existing = readDailyFromStorage(pubkey); if (existing) { hydratedRef.current = pubkey; return; } - // Parse persisted missions from profile content + // Parse persisted daily missions from profile content const parsed = parseProfileContent(profileContent); if (parsed.missions && !needsDailyReset(parsed.missions)) { - // Daily missions are still current — hydrate the full object - hydrateFromPersisted(parsed.missions, pubkey); - } else if (parsed.missions?.evolution?.length) { - // Daily missions need a reset, but evolution missions survive across days. - // Seed the store with fresh dailies + persisted evolution so the raw memo - // picks them up instead of creating missions with evolution: []. - const fresh = createDailyMissionsContent( - getTodayDateString(), - parsed.missions.evolution, - pubkey, - availableStages, - ); - writeMissionsToStorage(fresh, pubkey); + // Daily missions are still current — hydrate + hydrateDailyFromPersisted(parsed.missions, pubkey); } + // If daily missions need a reset, the raw memo below will create fresh ones. + hydratedRef.current = pubkey; setVersion((v) => v + 1); - }, [pubkey, profileContent]); // eslint-disable-line react-hooks/exhaustive-deps + }, [pubkey, profileContent]); // Listen for tracker events useEffect(() => { @@ -152,11 +146,9 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail // Read and ensure current state. // CRITICAL: Don't create a fresh store entry until hydration is complete. - // Creating one prematurely would overwrite persisted evolution missions - // because `hydrateFromPersisted` no-ops when the store already has data. const hydrated = hydratedRef.current === pubkey; const raw = useMemo((): MissionsContent | undefined => { - const stored = readMissionsFromStorage(pubkey); + const stored = readDailyFromStorage(pubkey); if (!needsDailyReset(stored)) return stored; @@ -164,14 +156,13 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail // hydration effect to seed persisted data before creating fresh missions. if (!stored && !hydrated) return undefined; - // Reset for new day, preserve evolution missions + // Reset for new day const fresh = createDailyMissionsContent( getTodayDateString(), - stored?.evolution ?? [], pubkey, availableStages, ); - writeMissionsToStorage(fresh, pubkey); + writeDailyToStorage(fresh, pubkey); return fresh; // eslint-disable-next-line react-hooks/exhaustive-deps }, [version, pubkey, stagesKey, hydrated]); @@ -203,14 +194,13 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail const forceReset = useCallback(() => { const fresh = createDailyMissionsContent( getTodayDateString(), - raw?.evolution ?? [], pubkey, availableStages, ); - writeMissionsToStorage(fresh, pubkey); + writeDailyToStorage(fresh, pubkey); setVersion((v) => v + 1); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pubkey, stagesKey, raw?.evolution]); + }, [pubkey, stagesKey]); return { missions, diff --git a/src/blobbi/actions/hooks/useEvolveTasks.ts b/src/blobbi/actions/hooks/useEvolveTasks.ts index 1e61cb04..4150395d 100644 --- a/src/blobbi/actions/hooks/useEvolveTasks.ts +++ b/src/blobbi/actions/hooks/useEvolveTasks.ts @@ -3,7 +3,7 @@ /** * Hook to compute evolve task progress. * - * Progress is stored in `MissionsContent.evolution[]` on kind 11125. + * Progress is stored in the kind 31124 Blobbi event content JSON (per-Blobbi). * - Interactions: TallyMission tracked via `trackEvolutionMissionTally` * - Event-based tasks: EventMission, backfilled from retroactive Nostr queries * - Dynamic task (maintain_stats): computed from current companion stats, NEVER stored @@ -16,9 +16,14 @@ import type { NostrFilter } from '@nostrify/nostrify'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi'; -import type { MissionsContent } from '@/blobbi/core/lib/missions'; +import type { Mission } from '@/blobbi/core/lib/missions'; import { missionProgress, isEventMission } from '@/blobbi/core/lib/missions'; -import { trackEvolutionMissionEvent, readMissionsFromStorage, ensureSessionStore, writeMissionsToStorage } from '../lib/daily-mission-tracker'; +import { + trackEvolutionMissionEvent, + readEvolutionFromStorage, + writeEvolutionToStorage, + hydrateEvolutionFromPersisted, +} from '../lib/daily-mission-tracker'; import { EVOLVE_MISSIONS, EVOLVE_REQUIRED_INTERACTIONS, @@ -80,45 +85,64 @@ export interface EvolveTasksResult { * Hook to compute evolve task progress from evolution missions + Nostr event backfill. * * @param companion - The Blobbi companion (must be in evolving state) - * @param missions - Current MissionsContent from the session store */ export function useEvolveTasks( companion: BlobbiCompanion | null, - missions: MissionsContent | undefined, ): EvolveTasksResult { const { user } = useCurrentUser(); const { nostr } = useNostr(); const pubkey = user?.pubkey; + const companionD = companion?.d; const isEvolving = companion?.progressionState === 'evolving'; - const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]); + + // Read evolution from session store or companion (31124 content) + const evolution = useMemo((): Mission[] => { + if (!pubkey || !companionD) return []; + const fromStore = readEvolutionFromStorage(pubkey, companionD); + if (fromStore && fromStore.length > 0) return fromStore; + return companion?.evolution ?? []; + }, [pubkey, companionD, companion?.evolution]); + + // ─── Hydrate evolution store from companion on mount ─── + const hydratedRef = useRef(null); + useEffect(() => { + if (!isEvolving || !pubkey || !companionD) return; + const hydrateKey = `${pubkey}:${companionD}`; + if (hydratedRef.current === hydrateKey) return; + hydratedRef.current = hydrateKey; + + const companionEvolution = companion?.evolution ?? []; + if (companionEvolution.length > 0) { + hydrateEvolutionFromPersisted(companionEvolution, pubkey, companionD); + } + }, [isEvolving, pubkey, companionD, companion?.evolution]); // ─── Ensure evolution missions exist and match current definitions ─── - // Safety net: if the companion is evolving but evolution[] is empty - // (e.g. persist didn't fire, hydration lost them), re-populate from - // the static definitions so tally tracking works immediately. - // Also handles schema migrations: if persisted missions don't match - // the current EVOLVE_MISSIONS (e.g. a mission was added or removed), - // rebuild from definitions while preserving progress for surviving missions. - const ensuredRef = useRef(false); + // Scoped by pubkey:d so switching Blobbis re-runs the check. + const ensuredRef = useRef(null); useEffect(() => { - if (!isEvolving || !pubkey || ensuredRef.current) return; + const ensureKey = `${pubkey}:${companionD}`; + if (!isEvolving || !pubkey || !companionD || ensuredRef.current === ensureKey) return; - const store = ensureSessionStore(pubkey); - if (store.evolution.length === 0) { - writeMissionsToStorage({ ...store, evolution: createEvolveMissions() }, pubkey); - window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } })); - } else if (!evolutionMatchesDefinitions(store.evolution, EVOLVE_MISSIONS)) { - const migrated = migrateEvolutionMissions(store.evolution, EVOLVE_MISSIONS); - writeMissionsToStorage({ ...store, evolution: migrated }, pubkey); - window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } })); + const fromStore = readEvolutionFromStorage(pubkey, companionD); + const current = fromStore && fromStore.length > 0 ? fromStore : (companion?.evolution ?? []); + + if (current.length === 0) { + const fresh = createEvolveMissions(); + writeEvolutionToStorage(fresh, pubkey, companionD); + window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: companionD } })); + } else if (!evolutionMatchesDefinitions(current, EVOLVE_MISSIONS)) { + const migrated = migrateEvolutionMissions(current, EVOLVE_MISSIONS); + writeEvolutionToStorage(migrated, pubkey, companionD); + window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: companionD } })); } - ensuredRef.current = true; - }, [isEvolving, pubkey, evolution]); + ensuredRef.current = ensureKey; + }, [isEvolving, pubkey, companionD, companion?.evolution]); // ─── Retroactive Nostr Queries (discover event IDs to backfill) ─── const { data, isLoading, error, refetch } = useQuery({ - queryKey: ['evolve-tasks', pubkey], + queryKey: ['evolve-tasks', pubkey, companionD], queryFn: async () => { if (!pubkey) return null; @@ -144,7 +168,6 @@ export function useEvolveTasks( }); // ─── Compute event counts directly from Nostr query results ─── - // These are the authoritative counts for event-based tasks. const queryCounts: Record = useMemo(() => { if (!data) return {} as Record; return { @@ -158,24 +181,23 @@ export function useEvolveTasks( const lastBackfilledDataRef = useRef(null); useEffect(() => { - if (!data || !pubkey || evolution.length === 0) return; + if (!data || !pubkey || !companionD || evolution.length === 0) return; if (data === lastBackfilledDataRef.current) return; lastBackfilledDataRef.current = data; - const current = readMissionsFromStorage(pubkey); - if (!current || current.evolution.length === 0) return; - const evo = current.evolution; + const current = readEvolutionFromStorage(pubkey, companionD); + if (!current || current.length === 0) return; for (const event of data.themeEvents) { - const m = findEvolutionMission(evo, 'create_themes'); + const m = findEvolutionMission(current, 'create_themes'); if (m && isEventMission(m) && !m.events.includes(event.id)) { - trackEvolutionMissionEvent('create_themes', event.id, pubkey); + trackEvolutionMissionEvent('create_themes', event.id, pubkey, companionD); } } for (const event of data.colorMomentEvents) { - const m = findEvolutionMission(evo, 'color_moments'); + const m = findEvolutionMission(current, 'color_moments'); if (m && isEventMission(m) && !m.events.includes(event.id)) { - trackEvolutionMissionEvent('color_moments', event.id, pubkey); + trackEvolutionMissionEvent('color_moments', event.id, pubkey, companionD); } } const profileEditEvents = [ @@ -183,18 +205,14 @@ export function useEvolveTasks( ...(data.hasProfileMetadata ? [{ id: 'profile-metadata' }] : []), ]; for (const event of profileEditEvents) { - const m = findEvolutionMission(evo, 'edit_profile'); + const m = findEvolutionMission(current, 'edit_profile'); if (m && isEventMission(m) && !m.events.includes(event.id)) { - trackEvolutionMissionEvent('edit_profile', event.id, pubkey); + trackEvolutionMissionEvent('edit_profile', event.id, pubkey, companionD); } } - }, [data, pubkey, evolution]); + }, [data, pubkey, companionD, evolution]); // ─── Build task view models ─── - // For event-based tasks, use the MAX of the Nostr query count and the - // evolution mission progress. The query is authoritative but the mission - // store may have progress from a previous session that hasn't been - // re-queried yet. const tasks: HatchTask[] = EVOLVE_MISSIONS.map((def) => { const mission = findEvolutionMission(evolution, def.id); const missionCount = mission ? missionProgress(mission) : 0; @@ -261,5 +279,3 @@ export function useEvolveTasks( refetch, }; } - - diff --git a/src/blobbi/actions/hooks/useHatchTasks.ts b/src/blobbi/actions/hooks/useHatchTasks.ts index 400050a4..66e32430 100644 --- a/src/blobbi/actions/hooks/useHatchTasks.ts +++ b/src/blobbi/actions/hooks/useHatchTasks.ts @@ -3,13 +3,13 @@ /** * Hook to compute hatch task progress. * - * Progress is stored in `MissionsContent.evolution[]` on kind 11125. + * Progress is stored in the kind 31124 Blobbi event content JSON (per-Blobbi). * - Interactions: TallyMission tracked via `trackEvolutionMissionTally` * - Event-based tasks: EventMission, backfilled from retroactive Nostr queries * * The Nostr queries discover event IDs that satisfy event-based tasks and - * feed them into the evolution tracker. The evolution array is the source of - * truth for completion state. + * feed them into the evolution tracker. The evolution array (from companion + * or session store) is the source of truth for completion state. */ import { useEffect, useRef, useMemo } from 'react'; @@ -19,9 +19,14 @@ import type { NostrFilter } from '@nostrify/nostrify'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi'; -import type { MissionsContent } from '@/blobbi/core/lib/missions'; +import type { Mission } from '@/blobbi/core/lib/missions'; import { missionProgress, isEventMission } from '@/blobbi/core/lib/missions'; -import { trackEvolutionMissionEvent, readMissionsFromStorage, ensureSessionStore, writeMissionsToStorage } from '../lib/daily-mission-tracker'; +import { + trackEvolutionMissionEvent, + readEvolutionFromStorage, + writeEvolutionToStorage, + hydrateEvolutionFromPersisted, +} from '../lib/daily-mission-tracker'; import { HATCH_MISSIONS, HATCH_REQUIRED_INTERACTIONS, @@ -99,45 +104,71 @@ export interface HatchTasksResult { * Hook to compute hatch task progress from evolution missions + Nostr event backfill. * * @param companion - The Blobbi companion (must be incubating) - * @param missions - Current MissionsContent from the session store */ export function useHatchTasks( companion: BlobbiCompanion | null, - missions: MissionsContent | undefined, ): HatchTasksResult { const { user } = useCurrentUser(); const { nostr } = useNostr(); const pubkey = user?.pubkey; + const companionD = companion?.d; const isIncubating = companion?.progressionState === 'incubating'; - const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]); + + // Read evolution from companion (31124 content) or session store + const evolution = useMemo((): Mission[] => { + if (!pubkey || !companionD) return []; + // Session store takes priority (has latest in-session progress) + const fromStore = readEvolutionFromStorage(pubkey, companionD); + if (fromStore && fromStore.length > 0) return fromStore; + // Fall back to companion's persisted evolution from 31124 content + return companion?.evolution ?? []; + }, [pubkey, companionD, companion?.evolution]); + + // ─── Hydrate evolution store from companion on mount ─── + // If the companion has persisted evolution data but the session store is empty, + // seed the session store so tally tracking works immediately. + const hydratedRef = useRef(null); + useEffect(() => { + if (!isIncubating || !pubkey || !companionD) return; + const hydrateKey = `${pubkey}:${companionD}`; + if (hydratedRef.current === hydrateKey) return; + hydratedRef.current = hydrateKey; + + const companionEvolution = companion?.evolution ?? []; + if (companionEvolution.length > 0) { + hydrateEvolutionFromPersisted(companionEvolution, pubkey, companionD); + } + }, [isIncubating, pubkey, companionD, companion?.evolution]); // ─── Ensure evolution missions exist and match current definitions ─── // Safety net: if the companion is incubating but evolution[] is empty - // (e.g. persist didn't fire, hydration lost them), re-populate from + // (e.g. persist didn't fire, old content format), re-populate from // the static definitions so tally tracking works immediately. - // Also handles schema migrations: if persisted missions don't match - // the current HATCH_MISSIONS (e.g. a mission was added or removed), - // rebuild from definitions while preserving progress for surviving missions. - const ensuredRef = useRef(false); + // Scoped by pubkey:d so switching Blobbis re-runs the check. + const ensuredRef = useRef(null); useEffect(() => { - if (!isIncubating || !pubkey || ensuredRef.current) return; + const ensureKey = `${pubkey}:${companionD}`; + if (!isIncubating || !pubkey || !companionD || ensuredRef.current === ensureKey) return; - const store = ensureSessionStore(pubkey); - if (store.evolution.length === 0) { - writeMissionsToStorage({ ...store, evolution: createHatchMissions() }, pubkey); - window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } })); - } else if (!evolutionMatchesDefinitions(store.evolution, HATCH_MISSIONS)) { - const migrated = migrateEvolutionMissions(store.evolution, HATCH_MISSIONS); - writeMissionsToStorage({ ...store, evolution: migrated }, pubkey); - window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } })); + const fromStore = readEvolutionFromStorage(pubkey, companionD); + const current = fromStore && fromStore.length > 0 ? fromStore : (companion?.evolution ?? []); + + if (current.length === 0) { + const fresh = createHatchMissions(); + writeEvolutionToStorage(fresh, pubkey, companionD); + window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: companionD } })); + } else if (!evolutionMatchesDefinitions(current, HATCH_MISSIONS)) { + const migrated = migrateEvolutionMissions(current, HATCH_MISSIONS); + writeEvolutionToStorage(migrated, pubkey, companionD); + window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: companionD } })); } - ensuredRef.current = true; - }, [isIncubating, pubkey, evolution]); + ensuredRef.current = ensureKey; + }, [isIncubating, pubkey, companionD, companion?.evolution]); // ─── Retroactive Nostr Queries (discover event IDs to backfill) ─── const { data, isLoading, error, refetch } = useQuery({ - queryKey: ['hatch-tasks', pubkey], + queryKey: ['hatch-tasks', pubkey, companionD], queryFn: async () => { if (!pubkey) return null; @@ -159,7 +190,6 @@ export function useHatchTasks( }); // ─── Compute event counts directly from Nostr query results ─── - // These are the authoritative counts for event-based tasks. const queryCounts: Record = useMemo(() => { if (!data) return {} as Record; return { @@ -172,33 +202,28 @@ export function useHatchTasks( const lastBackfilledDataRef = useRef(null); useEffect(() => { - if (!data || !pubkey || evolution.length === 0) return; + if (!data || !pubkey || !companionD || evolution.length === 0) return; if (data === lastBackfilledDataRef.current) return; lastBackfilledDataRef.current = data; - const current = readMissionsFromStorage(pubkey); - if (!current || current.evolution.length === 0) return; - const evo = current.evolution; + const current = readEvolutionFromStorage(pubkey, companionD); + if (!current || current.length === 0) return; for (const event of data.themeEvents) { - const m = findEvolutionMission(evo, 'create_theme'); + const m = findEvolutionMission(current, 'create_theme'); if (m && isEventMission(m) && !m.events.includes(event.id)) { - trackEvolutionMissionEvent('create_theme', event.id, pubkey); + trackEvolutionMissionEvent('create_theme', event.id, pubkey, companionD); } } for (const event of data.colorMomentEvents) { - const m = findEvolutionMission(evo, 'color_moment'); + const m = findEvolutionMission(current, 'color_moment'); if (m && isEventMission(m) && !m.events.includes(event.id)) { - trackEvolutionMissionEvent('color_moment', event.id, pubkey); + trackEvolutionMissionEvent('color_moment', event.id, pubkey, companionD); } } - }, [data, pubkey, evolution]); + }, [data, pubkey, companionD, evolution]); // ─── Build task view models ─── - // For event-based tasks, use the MAX of the Nostr query count and the - // evolution mission progress. The query is authoritative but the mission - // store may have progress from a previous session that hasn't been - // re-queried yet. const tasks: HatchTask[] = HATCH_MISSIONS.map((def) => { const mission = findEvolutionMission(evolution, def.id); const missionCount = mission ? missionProgress(mission) : 0; diff --git a/src/blobbi/actions/hooks/usePersistEvolutionProgress.ts b/src/blobbi/actions/hooks/usePersistEvolutionProgress.ts index 63f2a37e..68bf79a3 100644 --- a/src/blobbi/actions/hooks/usePersistEvolutionProgress.ts +++ b/src/blobbi/actions/hooks/usePersistEvolutionProgress.ts @@ -1,10 +1,9 @@ /** * usePersistEvolutionProgress - Debounced persistence for evolution mission progress. * - * Evolution missions (hatch/evolve tasks) live in `MissionsContent.evolution[]` - * in the in-memory session store. This hook listens for changes and debounce- - * publishes the updated state to kind 11125 content JSON so progress survives - * page refreshes. + * Evolution missions live in the per-Blobbi session store (keyed by pubkey:d). + * This hook listens for changes and debounce-publishes the updated state to the + * kind 31124 Blobbi event content JSON so progress survives page refreshes. * * Design: * - Listens to 'daily-missions-updated' CustomEvent (same event the tracker fires) @@ -23,10 +22,10 @@ import { useNostrPublish } from '@/hooks/useNostrPublish'; import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; import { - KIND_BLOBBONAUT_PROFILE, + KIND_BLOBBI_STATE, } from '@/blobbi/core/lib/blobbi'; -import { serializeProfileContent } from '@/blobbi/core/lib/missions'; -import { readMissionsFromStorage } from '../lib/daily-mission-tracker'; +import { serializeEvolutionContent } from '@/blobbi/core/lib/missions'; +import { readEvolutionFromStorage } from '../lib/daily-mission-tracker'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -38,10 +37,12 @@ const PERSIST_DELAY_MS = 5_000; // ─── Hook ───────────────────────────────────────────────────────────────────── /** - * @param updateProfileEvent - Callback to update profile in query cache + * @param companionD - The d-tag of the active Blobbi (required for per-Blobbi storage) + * @param updateCompanionEvent - Callback to update companion in query cache */ export function usePersistEvolutionProgress( - updateProfileEvent: (event: NostrEvent) => void, + companionD: string | undefined, + updateCompanionEvent: (event: NostrEvent) => void, ): void { const { user } = useCurrentUser(); const { nostr } = useNostr(); @@ -53,42 +54,56 @@ export function usePersistEvolutionProgress( const persist = useCallback(async () => { const pubkey = user?.pubkey; - if (!pubkey || publishingRef.current) return; + if (!pubkey || !companionD || publishingRef.current) return; - const missions = readMissionsFromStorage(pubkey); - if (!missions || missions.evolution.length === 0) return; + const evolution = readEvolutionFromStorage(pubkey, companionD); + if (!evolution || evolution.length === 0) return; publishingRef.current = true; try { + // Fetch the fresh Blobbi event from relays const prev = await fetchFreshEvent(nostr, { - kinds: [KIND_BLOBBONAUT_PROFILE], + kinds: [KIND_BLOBBI_STATE], authors: [pubkey], + '#d': [companionD], }); - const content = serializeProfileContent( - prev?.content ?? '', - { missions }, - ); + if (!prev) { + console.warn('[PersistEvolution] No Blobbi event found for d-tag:', companionD); + return; + } + + const content = serializeEvolutionContent(prev.content, evolution); + + // Skip publish if the content is already up-to-date. + // This avoids redundant replaceable-event publishes when the + // primary interaction write path already persisted the same data. + if (content === prev.content) return; const event = await publishEvent({ - kind: KIND_BLOBBONAUT_PROFILE, + kind: KIND_BLOBBI_STATE, content, - tags: prev?.tags ?? [], - prev: prev ?? undefined, + tags: prev.tags, + prev, }); - updateProfileEvent(event); - queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', pubkey] }); + updateCompanionEvent(event); + queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] }); } finally { publishingRef.current = false; } - }, [user?.pubkey, nostr, publishEvent, updateProfileEvent, queryClient]); + }, [user?.pubkey, companionD, nostr, publishEvent, updateCompanionEvent, queryClient]); useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail; if (!detail?.evolution) return; + // Only react to evolution updates for the active companion. + // detail.d is set by trackEvolutionMissionTally/Event; if absent + // (legacy caller), accept it to avoid silently dropping updates. + if (detail.d && detail.d !== companionD) return; + // Clear any pending timer and restart the debounce if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { @@ -103,5 +118,5 @@ export function usePersistEvolutionProgress( window.removeEventListener('daily-missions-updated', handler); if (timerRef.current) clearTimeout(timerRef.current); }; - }, [persist]); + }, [persist, companionD]); } diff --git a/src/blobbi/actions/lib/daily-mission-tracker.ts b/src/blobbi/actions/lib/daily-mission-tracker.ts index 954f76e8..ed81fa12 100644 --- a/src/blobbi/actions/lib/daily-mission-tracker.ts +++ b/src/blobbi/actions/lib/daily-mission-tracker.ts @@ -1,16 +1,18 @@ /** * Daily Mission Tracker - Standalone progress tracking utility * - * Provides a way to record daily mission progress from anywhere - * (hooks, event handlers, etc.) without requiring React context. + * Two separate in-memory stores: + * - dailyStore: pubkey-scoped, for daily missions (kind 11125) + * - evolutionStore: pubkey:d-scoped, for per-Blobbi evolution missions (kind 31124) * - * Uses a pubkey-scoped in-memory Map. Kind 11125 content JSON is the - * persistent source of truth. Completed missions are persisted by - * `useAwardDailyXp`; intermediate progress resets on page refresh. + * Both cleared on page refresh. The persistent source of truth is: + * - Daily missions → kind 11125 content JSON + * - Evolution missions → kind 31124 content JSON * * Dispatches 'daily-missions-updated' CustomEvent so React hooks re-render. */ +import type { Mission } from '@/blobbi/core/lib/missions'; import type { MissionsContent } from '@/blobbi/core/lib/missions'; import type { DailyMissionAction } from './daily-missions'; import { @@ -19,31 +21,31 @@ import { createDailyMissionsContent, trackTally, trackEvent, - trackEvolutionTally, - trackEvolutionEvent, + trackEvolutionTally as trackEvoTally, + trackEvolutionEvent as trackEvoEvent, } from './daily-missions'; -// ─── In-Memory Session Store ────────────────────────────────────────────────── +// ─── Daily Mission Session Store (per-user) ────────────────────────────────── /** - * Pubkey-scoped session cache. Each logged-in user gets their own entry. + * Pubkey-scoped session cache for daily missions. * Cleared on page refresh (intentional — kind 11125 is the persistent store). */ -const sessionStore = new Map(); +const dailyStore = new Map(); -function key(pubkey: string | undefined): string { +function dailyKey(pubkey: string | undefined): string { return pubkey ?? ''; } -function ensureCurrent(pubkey?: string): MissionsContent { - const current = sessionStore.get(key(pubkey)); +function ensureDailyCurrent(pubkey?: string, availableStages?: import('./daily-missions').BlobbiStage[]): MissionsContent { + const current = dailyStore.get(dailyKey(pubkey)); if (!needsDailyReset(current)) return current!; const fresh = createDailyMissionsContent( getTodayDateString(), - current?.evolution ?? [], pubkey, + availableStages, ); - sessionStore.set(key(pubkey), fresh); + dailyStore.set(dailyKey(pubkey), fresh); return fresh; } @@ -51,7 +53,20 @@ function notify(detail?: Record): void { window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail })); } -// ─── Public API ─────────────────────────────────────────────────────────────── +// ─── Evolution Mission Session Store (per-Blobbi) ──────────────────────────── + +/** + * Per-Blobbi session cache for evolution missions. + * Keyed by `pubkey:d` so each Blobbi has its own evolution progress. + * Cleared on page refresh — kind 31124 content is the persistent store. + */ +const evolutionStore = new Map(); + +function evoKey(pubkey: string | undefined, d: string | undefined): string { + return `${pubkey ?? ''}:${d ?? ''}`; +} + +// ─── Public API: Daily Missions ────────────────────────────────────────────── /** * Record a tally-based action (feed, clean, interact, etc.). @@ -61,9 +76,9 @@ export function trackDailyMissionProgress( count: number = 1, pubkey?: string, ): void { - const current = ensureCurrent(pubkey); + const current = ensureDailyCurrent(pubkey); const updated = trackTally(current, action, count); - sessionStore.set(key(pubkey), updated); + dailyStore.set(dailyKey(pubkey), updated); notify({ action, count }); } @@ -75,9 +90,9 @@ export function trackDailyMissionEvent( eventId: string, pubkey?: string, ): void { - const current = ensureCurrent(pubkey); + const current = ensureDailyCurrent(pubkey); const updated = trackEvent(current, action, eventId); - sessionStore.set(key(pubkey), updated); + dailyStore.set(dailyKey(pubkey), updated); notify({ action, eventId }); } @@ -88,80 +103,130 @@ export function trackMultipleDailyMissionActions( actions: DailyMissionAction[], pubkey?: string, ): void { - let current = ensureCurrent(pubkey); + let current = ensureDailyCurrent(pubkey); for (const action of actions) { current = trackTally(current, action, 1); } - sessionStore.set(key(pubkey), current); + dailyStore.set(dailyKey(pubkey), current); notify({ actions }); } -// ─── Evolution Mission Tracking ─────────────────────────────────────────────── +// ─── Public API: Evolution Missions (per-Blobbi) ───────────────────────────── /** * Increment tally for an evolution mission (e.g. interactions). - * No-ops if pubkey missing or session store empty. + * No-ops if the store is empty for this Blobbi. */ export function trackEvolutionMissionTally( missionId: string, count: number = 1, pubkey?: string, + d?: string, ): void { - const current = sessionStore.get(key(pubkey)); - if (!current) return; + const k = evoKey(pubkey, d); + const current = evolutionStore.get(k); + if (!current || current.length === 0) return; - const updated = trackEvolutionTally(current, missionId, count); - sessionStore.set(key(pubkey), updated); - notify({ evolution: true, missionId, count }); + const updated = trackEvoTally(current, missionId, count); + evolutionStore.set(k, updated); + notify({ evolution: true, missionId, count, d }); } /** * Append a Nostr event ID to an evolution mission (e.g. create_theme). - * Deduplicates by event ID. No-ops if pubkey missing or session store empty. + * Deduplicates by event ID. No-ops if the store is empty for this Blobbi. */ export function trackEvolutionMissionEvent( missionId: string, eventId: string, pubkey?: string, + d?: string, ): void { - const current = sessionStore.get(key(pubkey)); - if (!current) return; + const k = evoKey(pubkey, d); + const current = evolutionStore.get(k); + if (!current || current.length === 0) return; - const updated = trackEvolutionEvent(current, missionId, eventId); - sessionStore.set(key(pubkey), updated); - notify({ evolution: true, missionId, eventId }); + const updated = trackEvoEvent(current, missionId, eventId); + evolutionStore.set(k, updated); + notify({ evolution: true, missionId, eventId, d }); } -// ─── Storage Access ────────────────────────────────────────────────────────── +// ─── Storage Access: Daily ─────────────────────────────────────────────────── -/** Read current session state for a pubkey. */ -export function readMissionsFromStorage(pubkey?: string): MissionsContent | undefined { - return sessionStore.get(key(pubkey)); +/** Read current daily session state for a pubkey. */ +export function readDailyFromStorage(pubkey?: string): MissionsContent | undefined { + return dailyStore.get(dailyKey(pubkey)); } /** - * Ensure the session store has an entry for the given pubkey. - * If the store is empty or needs a daily reset, a fresh entry is created. + * Ensure the daily store has an entry for the given pubkey. * Returns the current (possibly newly-created) MissionsContent. - * - * Use this before writing evolution missions into the store, to avoid - * silent no-ops when the store hasn't been hydrated yet. */ -export function ensureSessionStore(pubkey?: string): MissionsContent { - return ensureCurrent(pubkey); +export function ensureDailyStore(pubkey?: string): MissionsContent { + return ensureDailyCurrent(pubkey); } -/** Write state to session store for a pubkey. */ -export function writeMissionsToStorage(missions: MissionsContent, pubkey?: string): void { - sessionStore.set(key(pubkey), missions); +/** Write daily state to session store for a pubkey. */ +export function writeDailyToStorage(missions: MissionsContent, pubkey?: string): void { + dailyStore.set(dailyKey(pubkey), missions); } /** - * Hydrate the session store from kind 11125 persisted data. + * Hydrate the daily session store from kind 11125 persisted data. * Called once on mount / account switch when the session store is empty. * No-op if the store already has data for this pubkey. */ -export function hydrateFromPersisted(missions: MissionsContent, pubkey: string): void { - if (sessionStore.has(pubkey)) return; - sessionStore.set(pubkey, missions); +export function hydrateDailyFromPersisted(missions: MissionsContent, pubkey: string): void { + if (dailyStore.has(pubkey)) return; + dailyStore.set(pubkey, missions); } + +// ─── Storage Access: Evolution (per-Blobbi) ────────────────────────────────── + +/** Read current evolution session state for a specific Blobbi. */ +export function readEvolutionFromStorage(pubkey?: string, d?: string): Mission[] | undefined { + return evolutionStore.get(evoKey(pubkey, d)); +} + +/** Write evolution state for a specific Blobbi. */ +export function writeEvolutionToStorage(evolution: Mission[], pubkey?: string, d?: string): void { + evolutionStore.set(evoKey(pubkey, d), evolution); +} + +/** + * Hydrate the evolution session store from kind 31124 content. + * Called once when a companion with active progression is loaded. + * No-op if the store already has data for this Blobbi. + */ +export function hydrateEvolutionFromPersisted(evolution: Mission[], pubkey: string, d: string): void { + const k = evoKey(pubkey, d); + if (evolutionStore.has(k)) return; + evolutionStore.set(k, evolution); +} + +/** Clear evolution store for a specific Blobbi (on stage transition / stop). */ +export function clearEvolutionFromStorage(pubkey?: string, d?: string): void { + evolutionStore.delete(evoKey(pubkey, d)); +} + +// ─── Backward-compat aliases ───────────────────────────────────────────────── + +/** + * @deprecated Use readDailyFromStorage. Kept for callers that haven't migrated. + */ +export const readMissionsFromStorage = readDailyFromStorage; + +/** + * @deprecated Use writeDailyToStorage. Kept for callers that haven't migrated. + */ +export const writeMissionsToStorage = writeDailyToStorage; + +/** + * @deprecated Use ensureDailyStore. Kept for callers that haven't migrated. + */ +export const ensureSessionStore = ensureDailyStore; + +/** + * @deprecated Use hydrateDailyFromPersisted. Kept for callers that haven't migrated. + */ +export const hydrateFromPersisted = hydrateDailyFromPersisted; diff --git a/src/blobbi/actions/lib/daily-missions.ts b/src/blobbi/actions/lib/daily-missions.ts index 8f3821b3..16d578c9 100644 --- a/src/blobbi/actions/lib/daily-missions.ts +++ b/src/blobbi/actions/lib/daily-missions.ts @@ -267,10 +267,9 @@ export function createMission(def: DailyMissionDefinition): Mission { return { id: def.id, target: def.target, count: 0 } satisfies TallyMission; } -/** Create a fresh MissionsContent for a new day, preserving evolution missions */ +/** Create a fresh MissionsContent for a new day */ export function createDailyMissionsContent( dateString: string, - existingEvolution: Mission[], pubkey?: string, availableStages?: BlobbiStage[], ): MissionsContent { @@ -278,7 +277,6 @@ export function createDailyMissionsContent( return { date: dateString, daily: defs.map(createMission), - evolution: existingEvolution, rerolls: MAX_DAILY_REROLLS, }; } @@ -324,39 +322,41 @@ export function trackEvent( return { ...missions, daily: updated }; } +// ─── Evolution Mission Tracking (operates on Mission[] directly) ───────────── + /** - * Track progress for an evolution mission by tally. + * Increment tally for an evolution mission by ID. + * Returns a new array (immutable). Used by the evolution session store. */ export function trackEvolutionTally( - missions: MissionsContent, + evolution: Mission[], missionId: string, incrementBy: number = 1, -): MissionsContent { - const updated = missions.evolution.map((m) => { +): Mission[] { + return evolution.map((m) => { if (m.id !== missionId) return m; if (!isTallyMission(m)) return m; if (m.count >= m.target) return m; return { ...m, count: Math.min(m.count + incrementBy, m.target) }; }); - return { ...missions, evolution: updated }; } /** - * Append an event ID to an evolution mission. + * Append a Nostr event ID to an evolution mission. + * Returns a new array (immutable). Used by the evolution session store. */ export function trackEvolutionEvent( - missions: MissionsContent, + evolution: Mission[], missionId: string, eventId: string, -): MissionsContent { - const updated = missions.evolution.map((m) => { +): Mission[] { + return evolution.map((m) => { if (m.id !== missionId) return m; if (!isEventMission(m)) return m; if (m.events.length >= m.target) return m; if (m.events.includes(eventId)) return m; return { ...m, events: [...m.events, eventId] }; }); - return { ...missions, evolution: updated }; } // ─── Completion Queries ────────────────────────────────────────────────────── @@ -366,11 +366,6 @@ export function areAllDailyComplete(missions: MissionsContent): boolean { return missions.daily.length > 0 && missions.daily.every(isMissionComplete); } -/** Whether all evolution missions are complete */ -export function areAllEvolutionComplete(missions: MissionsContent): boolean { - return missions.evolution.length > 0 && missions.evolution.every(isMissionComplete); -} - /** Total XP available from today's daily missions (including bonus if all complete) */ export function totalDailyXp(missions: MissionsContent): number { const base = missions.daily.reduce((sum, m) => { diff --git a/src/blobbi/companion/interaction/useBlobbiItemUse.ts b/src/blobbi/companion/interaction/useBlobbiItemUse.ts index ba2b0f8b..652a3360 100644 --- a/src/blobbi/companion/interaction/useBlobbiItemUse.ts +++ b/src/blobbi/companion/interaction/useBlobbiItemUse.ts @@ -48,8 +48,9 @@ import { type InventoryAction, ACTION_METADATA, } from '@/blobbi/actions/lib/blobbi-action-utils'; -import { trackMultipleDailyMissionActions, trackEvolutionMissionTally } from '@/blobbi/actions/lib/daily-mission-tracker'; +import { trackMultipleDailyMissionActions, trackEvolutionMissionTally, readEvolutionFromStorage } from '@/blobbi/actions/lib/daily-mission-tracker'; import type { DailyMissionAction } from '@/blobbi/actions/lib/daily-missions'; +import { serializeEvolutionContent } from '@/blobbi/core/lib/missions'; import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak'; import type { UseItemFunction } from './BlobbiActionsContextDef'; @@ -354,9 +355,18 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob const progressionState = companion.progressionState; const updatedTags = companion.allTags; if (progressionState === 'incubating' || progressionState === 'evolving') { - trackEvolutionMissionTally('interactions', 1, user?.pubkey); + trackEvolutionMissionTally('interactions', 1, user?.pubkey, companion.d); } + // ─── Build content with latest evolution state ─── + let content = companion.event.content; + if (progressionState === 'incubating' || progressionState === 'evolving') { + const evo = readEvolutionFromStorage(user?.pubkey, companion.d); + if (evo && evo.length > 0) { + content = serializeEvolutionContent(companion.event.content, evo); + } + } + // Get streak updates (will only update if needed based on day) const streakUpdates = getStreakTagUpdates(companion) ?? {}; @@ -369,7 +379,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob const blobbiEvent = await publishEvent({ kind: KIND_BLOBBI_STATE, - content: companion.event.content, + content, tags: blobbiTags, }); diff --git a/src/blobbi/core/lib/blobbi.ts b/src/blobbi/core/lib/blobbi.ts index 4bfd797a..cc19c155 100644 --- a/src/blobbi/core/lib/blobbi.ts +++ b/src/blobbi/core/lib/blobbi.ts @@ -3,6 +3,8 @@ import { bytesToHex } from '@noble/hashes/utils'; import type { NostrEvent } from '@nostrify/nostrify'; import { validateAndRepairBlobbiTags } from './blobbi-tag-schema'; +import type { Mission } from './missions'; +import { parseEvolutionContent } from './missions'; // ─── Constants ──────────────────────────────────────────────────────────────── @@ -300,6 +302,8 @@ export interface BlobbiCompanion { tasks: BlobbiTaskProgress[]; /** Completed task names */ tasksCompleted: string[]; + /** Evolution missions parsed from 31124 content JSON (per-Blobbi progression) */ + evolution: Mission[]; /** All tags preserved for republishing */ allTags: string[][]; } @@ -970,6 +974,9 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined } } + // Parse evolution missions from 31124 content JSON (per-Blobbi) + const evolution = parseEvolutionContent(event.content); + return { event, d, @@ -1002,6 +1009,7 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined progressionStartedAt: parseNumericTag(tags, 'progression_started_at') ?? parseNumericTag(tags, 'state_started_at'), tasks, tasksCompleted, + evolution, allTags: tags, }; } diff --git a/src/blobbi/core/lib/missions.ts b/src/blobbi/core/lib/missions.ts index c13a9958..524eec76 100644 --- a/src/blobbi/core/lib/missions.ts +++ b/src/blobbi/core/lib/missions.ts @@ -1,10 +1,9 @@ /** * Missions Content Model * - * Defines the JSON shape stored in the kind 11125 content field. - * Two mission categories: - * - daily: reset each day, tally-based or event-based - * - evolution: persist across sessions until stage transition completes + * Two separate persistence locations: + * - Daily missions → kind 11125 content JSON (per-user) + * - Evolution missions → kind 31124 content JSON (per-Blobbi) * * Tally missions track a `count` (no event IDs). * Event missions track an `events` array of Nostr event IDs. @@ -52,13 +51,12 @@ export function missionProgress(m: Mission): number { return m.count; } -// ─── Content Shape ─────────────────────────────────────────────────────────── +// ─── Daily Missions (kind 11125) ───────────────────────────────────────────── -/** The full missions object stored in kind 11125 content JSON */ +/** Daily missions object stored in kind 11125 content JSON */ export interface MissionsContent { date: string; // YYYY-MM-DD for daily reset detection daily: Mission[]; // 3 daily missions, reset each day - evolution: Mission[]; // active evolution missions, cleared on stage transition rerolls: number; // daily rerolls remaining (resets with date) } @@ -70,7 +68,55 @@ export interface ProfileContent { missions?: MissionsContent; } -// ─── Parse / Serialize ─────────────────────────────────────────────────────── +// ─── Evolution Missions (kind 31124) ───────────────────────────────────────── + +/** + * Evolution mission state stored in kind 31124 content JSON. + * Per-Blobbi progression that survives reloads. + */ +export interface EvolutionContent { + evolution: Mission[]; +} + +/** + * Parse evolution missions from a kind 31124 content field. + * Returns empty array for empty/invalid/non-JSON content. Never throws. + */ +export function parseEvolutionContent(content: string): Mission[] { + if (!content || !content.trim()) return []; + try { + const raw = JSON.parse(content); + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return []; + return parseMissionArray(raw.evolution); + } catch { + // Old-format content is plain text (e.g. "Luna is an egg...") — not JSON + return []; + } +} + +/** + * Serialize evolution missions into kind 31124 content JSON. + * Preserves any unknown top-level keys from the existing content. + */ +export function serializeEvolutionContent( + existingContent: string, + evolution: Mission[], +): string { + let base: Record = {}; + if (existingContent && existingContent.trim()) { + try { + const parsed = JSON.parse(existingContent); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + base = parsed; + } + } catch { + // Old-format text content — start fresh + } + } + return JSON.stringify({ ...base, evolution }); +} + +// ─── Profile Content (kind 11125) ──────────────────────────────────────────── /** * Parse the kind 11125 content field into a typed ProfileContent. @@ -94,6 +140,9 @@ export function parseProfileContent(content: string): ProfileContent { /** * Serialize ProfileContent back to a JSON string for publishing. * Preserves any unknown top-level keys from the existing content. + * + * NOTE: Strips any legacy `evolution` key from the missions object + * so old 11125 events don't carry stale per-Blobbi data. */ export function serializeProfileContent( existingContent: string, @@ -110,7 +159,17 @@ export function serializeProfileContent( // corrupt content -- start fresh but don't lose updates } } - return JSON.stringify({ ...base, ...updates }); + const merged = { ...base, ...updates }; + + // Strip legacy evolution from missions if present + if (merged.missions && typeof merged.missions === 'object' && !Array.isArray(merged.missions)) { + const m = merged.missions as unknown as Record; + if ('evolution' in m) { + delete m.evolution; + } + } + + return JSON.stringify(merged); } // ─── Internal Helpers ──────────────────────────────────────────────────────── @@ -120,11 +179,11 @@ function parseMissionsContent(raw: Record): MissionsContent | u return { date: raw.date, daily: parseMissionArray(raw.daily), - evolution: parseMissionArray(raw.evolution), rerolls: typeof raw.rerolls === 'number' ? Math.max(0, Math.floor(raw.rerolls)) : 0, }; } +/** @internal Exported for use by parseEvolutionContent */ function parseMissionArray(raw: unknown): Mission[] { if (!Array.isArray(raw)) return []; const result: Mission[] = []; diff --git a/src/blobbi/onboarding/lib/blobbi-preview.ts b/src/blobbi/onboarding/lib/blobbi-preview.ts index c5d63974..1d7252b9 100644 --- a/src/blobbi/onboarding/lib/blobbi-preview.ts +++ b/src/blobbi/onboarding/lib/blobbi-preview.ts @@ -201,6 +201,7 @@ export function previewToBlobbiCompanion(preview: BlobbiEggPreview) { progressionStartedAt: preview.createdAt, tasks: [], tasksCompleted: [], + evolution: [], // We need allTags for the adapter, but preview has no extra tags allTags: previewToEventTags(preview), diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index 44386269..2c7b7343 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -1048,19 +1048,19 @@ function BlobbiDashboard({ const canStartIncubation = isEgg && !isIncubating && !isEvolvingState; const canStartEvolution = isBaby && !isEvolvingState && !isIncubating; - // Daily missions (must be before hatch/evolve tasks which read evolution[] from it) + // Daily missions (per-user, kind 11125) const dailyMissions = useDailyMissions({ availableStages, profileContent: profile?.content }); // Hatch tasks hook - only active when incubating (egg stage) + // Evolution missions now come from companion (kind 31124), not dailyMissions const hatchTasks = useHatchTasks( isIncubating ? companion : null, - dailyMissions.raw, ); // Evolve tasks hook - only active when evolving (baby stage) + // Evolution missions now come from companion (kind 31124), not dailyMissions const evolveTasks = useEvolveTasks( isEvolvingState ? companion : null, - dailyMissions.raw, ); // ─── Unified Task Process Abstraction ─── @@ -1300,8 +1300,8 @@ function BlobbiDashboard({ setCurrentRoom('kitchen'); }; - // Persist evolution mission progress (debounced) so it survives page refresh - usePersistEvolutionProgress(updateProfileEvent); + // Persist evolution mission progress (debounced) to kind 31124 so it survives page refresh + usePersistEvolutionProgress(companion.d, updateCompanionEvent); // Award XP when all daily missions are complete const { mutate: awardDailyXp } = useAwardDailyXp(updateProfileEvent);