From 1bce67d21d0ccba4d7e69f55abe9cf808c80c58f Mon Sep 17 00:00:00 2001 From: filemon Date: Sat, 18 Apr 2026 08:59:19 -0300 Subject: [PATCH 1/3] Move Blobbi progression missions from kind 11125 to kind 31124 Evolution/hatch mission progress was stored in the shared Blobbonaut profile (kind 11125) content JSON, causing split-brain state between the per-user profile and per-Blobbi events. After reload, progress could disappear or get overwritten across Blobbis because the session store was keyed by pubkey only and persisted via a debounced write to the wrong event. Now: - Daily missions remain on kind 11125 (per-user, correct) - Evolution missions live on kind 31124 content JSON (per-Blobbi) - Session store split: daily keyed by pubkey, evolution by pubkey:d - usePersistEvolutionProgress writes to 31124 instead of 11125 - useHatchTasks/useEvolveTasks read from companion.evolution - serializeProfileContent strips legacy evolution from 11125 - Start/stop incubation/evolution seed/clear 31124 content directly - Interaction tallies pass companion d-tag for per-Blobbi tracking --- .../actions/hooks/useBlobbiDirectAction.ts | 2 +- .../actions/hooks/useBlobbiIncubation.ts | 63 ++++--- .../actions/hooks/useBlobbiStageTransition.ts | 44 +++-- .../hooks/useBlobbiUseInventoryItem.ts | 2 +- src/blobbi/actions/hooks/useDailyMissions.ts | 50 ++--- src/blobbi/actions/hooks/useEvolveTasks.ts | 94 ++++++---- src/blobbi/actions/hooks/useHatchTasks.ts | 95 ++++++---- .../hooks/usePersistEvolutionProgress.ts | 51 +++--- .../actions/lib/daily-mission-tracker.ts | 171 ++++++++++++------ src/blobbi/actions/lib/daily-missions.ts | 31 ++-- .../companion/interaction/useBlobbiItemUse.ts | 2 +- src/blobbi/core/lib/blobbi.ts | 8 + src/blobbi/core/lib/missions.ts | 79 +++++++- src/blobbi/onboarding/lib/blobbi-preview.ts | 1 + src/pages/BlobbiPage.tsx | 10 +- 15 files changed, 441 insertions(+), 262 deletions(-) diff --git a/src/blobbi/actions/hooks/useBlobbiDirectAction.ts b/src/blobbi/actions/hooks/useBlobbiDirectAction.ts index 06e2149d..7053c805 100644 --- a/src/blobbi/actions/hooks/useBlobbiDirectAction.ts +++ b/src/blobbi/actions/hooks/useBlobbiDirectAction.ts @@ -150,7 +150,7 @@ 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); } // Get streak updates (will only update if needed based on day) diff --git a/src/blobbi/actions/hooks/useBlobbiIncubation.ts b/src/blobbi/actions/hooks/useBlobbiIncubation.ts index 5775546e..705d5bdc 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 ──────────────────────────────────────────────────────────────────── @@ -261,22 +262,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 +425,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 +557,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 +705,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..9421d921 100644 --- a/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts +++ b/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts @@ -244,7 +244,7 @@ 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); } // Get streak updates (will only update if needed based on day) 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..c57bce23 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,41 +85,58 @@ 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); useEffect(() => { - if (!isEvolving || !pubkey || ensuredRef.current) return; + if (!isEvolving || !pubkey || !companionD || ensuredRef.current) 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]); + }, [isEvolving, pubkey, companionD, companion?.evolution]); // ─── Retroactive Nostr Queries (discover event IDs to backfill) ─── const { data, isLoading, error, refetch } = useQuery({ @@ -144,7 +166,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 +179,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 +203,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 +277,3 @@ export function useEvolveTasks( refetch, }; } - - diff --git a/src/blobbi/actions/hooks/useHatchTasks.ts b/src/blobbi/actions/hooks/useHatchTasks.ts index 400050a4..dd1d50aa 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,41 +104,65 @@ 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); useEffect(() => { - if (!isIncubating || !pubkey || ensuredRef.current) return; + if (!isIncubating || !pubkey || !companionD || ensuredRef.current) 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]); + }, [isIncubating, pubkey, companionD, companion?.evolution]); // ─── Retroactive Nostr Queries (discover event IDs to backfill) ─── const { data, isLoading, error, refetch } = useQuery({ @@ -159,7 +188,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 +200,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..d7690a2f 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,36 +54,40 @@ 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); 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) => { 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..3232778b 100644 --- a/src/blobbi/companion/interaction/useBlobbiItemUse.ts +++ b/src/blobbi/companion/interaction/useBlobbiItemUse.ts @@ -354,7 +354,7 @@ 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); } // Get streak updates (will only update if needed based on day) diff --git a/src/blobbi/core/lib/blobbi.ts b/src/blobbi/core/lib/blobbi.ts index 75ce711c..8c829e22 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 8088b78f..b3d5db66 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -1032,19 +1032,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 ─── @@ -1284,8 +1284,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); From 3fe12563818a8f476d7b0cedd406ceec6beb25dc Mon Sep 17 00:00:00 2001 From: filemon Date: Sat, 18 Apr 2026 09:23:08 -0300 Subject: [PATCH 2/3] Harden per-Blobbi evolution persistence across interaction paths Five targeted fixes to the evolution mission persistence flow: 1. Inline evolution content into interaction write paths: all three action hooks (direct action, inventory item, companion item use) now read the updated evolution from the session store and embed it into the 31124 content in the same publish. The debounce hook remains as a safety net for event-based backfill, not the primary persistence. 2. Scope ensuredRef per Blobbi: the 'ensure missions exist' guard in useHatchTasks and useEvolveTasks was a plain boolean ref that would not re-run when switching companions. Now keyed by pubkey:d. 3. Add companionD to query keys: hatch-tasks and evolve-tasks queries were keyed by pubkey only, causing stale cache reuse across Blobbis. 4. Filter persist hook by d-tag: usePersistEvolutionProgress now checks detail.d against companionD so it only reacts to evolution updates for the active companion. 5. Clear evolution in switch mode: when switching incubation from one Blobbi to another, the stopped Blobbi's 31124 content now has its evolution[] cleared, and its session store entry is removed. --- .../actions/hooks/useBlobbiDirectAction.ts | 16 ++++++++++++++-- src/blobbi/actions/hooks/useBlobbiIncubation.ts | 8 +++++++- .../actions/hooks/useBlobbiUseInventoryItem.ts | 14 ++++++++++++-- src/blobbi/actions/hooks/useEvolveTasks.ts | 10 ++++++---- src/blobbi/actions/hooks/useHatchTasks.ts | 10 ++++++---- .../actions/hooks/usePersistEvolutionProgress.ts | 7 ++++++- .../companion/interaction/useBlobbiItemUse.ts | 14 ++++++++++++-- 7 files changed, 63 insertions(+), 16 deletions(-) diff --git a/src/blobbi/actions/hooks/useBlobbiDirectAction.ts b/src/blobbi/actions/hooks/useBlobbiDirectAction.ts index 7053c805..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'; @@ -153,6 +154,17 @@ export function useBlobbiDirectAction({ 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 705d5bdc..419b1902 100644 --- a/src/blobbi/actions/hooks/useBlobbiIncubation.ts +++ b/src/blobbi/actions/hooks/useBlobbiIncubation.ts @@ -207,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); } } diff --git a/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts b/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts index 9421d921..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'; @@ -247,6 +248,15 @@ export function useBlobbiUseInventoryItem({ 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/useEvolveTasks.ts b/src/blobbi/actions/hooks/useEvolveTasks.ts index c57bce23..4150395d 100644 --- a/src/blobbi/actions/hooks/useEvolveTasks.ts +++ b/src/blobbi/actions/hooks/useEvolveTasks.ts @@ -119,9 +119,11 @@ export function useEvolveTasks( }, [isEvolving, pubkey, companionD, companion?.evolution]); // ─── Ensure evolution missions exist and match current definitions ─── - const ensuredRef = useRef(false); + // Scoped by pubkey:d so switching Blobbis re-runs the check. + const ensuredRef = useRef(null); useEffect(() => { - if (!isEvolving || !pubkey || !companionD || ensuredRef.current) return; + const ensureKey = `${pubkey}:${companionD}`; + if (!isEvolving || !pubkey || !companionD || ensuredRef.current === ensureKey) return; const fromStore = readEvolutionFromStorage(pubkey, companionD); const current = fromStore && fromStore.length > 0 ? fromStore : (companion?.evolution ?? []); @@ -135,12 +137,12 @@ export function useEvolveTasks( writeEvolutionToStorage(migrated, pubkey, companionD); window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: companionD } })); } - ensuredRef.current = true; + 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; diff --git a/src/blobbi/actions/hooks/useHatchTasks.ts b/src/blobbi/actions/hooks/useHatchTasks.ts index dd1d50aa..66e32430 100644 --- a/src/blobbi/actions/hooks/useHatchTasks.ts +++ b/src/blobbi/actions/hooks/useHatchTasks.ts @@ -145,9 +145,11 @@ export function useHatchTasks( // Safety net: if the companion is incubating but evolution[] is empty // (e.g. persist didn't fire, old content format), re-populate from // the static definitions so tally tracking works immediately. - const ensuredRef = useRef(false); + // Scoped by pubkey:d so switching Blobbis re-runs the check. + const ensuredRef = useRef(null); useEffect(() => { - if (!isIncubating || !pubkey || !companionD || ensuredRef.current) return; + const ensureKey = `${pubkey}:${companionD}`; + if (!isIncubating || !pubkey || !companionD || ensuredRef.current === ensureKey) return; const fromStore = readEvolutionFromStorage(pubkey, companionD); const current = fromStore && fromStore.length > 0 ? fromStore : (companion?.evolution ?? []); @@ -161,12 +163,12 @@ export function useHatchTasks( writeEvolutionToStorage(migrated, pubkey, companionD); window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true, d: companionD } })); } - ensuredRef.current = true; + 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; diff --git a/src/blobbi/actions/hooks/usePersistEvolutionProgress.ts b/src/blobbi/actions/hooks/usePersistEvolutionProgress.ts index d7690a2f..f8aa1557 100644 --- a/src/blobbi/actions/hooks/usePersistEvolutionProgress.ts +++ b/src/blobbi/actions/hooks/usePersistEvolutionProgress.ts @@ -94,6 +94,11 @@ export function usePersistEvolutionProgress( 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(() => { @@ -108,5 +113,5 @@ export function usePersistEvolutionProgress( window.removeEventListener('daily-missions-updated', handler); if (timerRef.current) clearTimeout(timerRef.current); }; - }, [persist]); + }, [persist, companionD]); } diff --git a/src/blobbi/companion/interaction/useBlobbiItemUse.ts b/src/blobbi/companion/interaction/useBlobbiItemUse.ts index 3232778b..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'; @@ -357,6 +358,15 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob 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, }); From 0618a1ca13772175bd615122809171f6197d72df Mon Sep 17 00:00:00 2001 From: filemon Date: Sat, 18 Apr 2026 09:33:06 -0300 Subject: [PATCH 3/3] Skip redundant evolution persist when content is already up-to-date The debounce hook was re-publishing a kind 31124 event with identical evolution content 5s after every interaction, because the primary write path already persisted the same data inline. Now compares the serialized content against the fresh event before publishing and skips when they match. The hook still fires for the one case where it is genuinely needed: event-based backfill from Nostr queries. --- src/blobbi/actions/hooks/usePersistEvolutionProgress.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/blobbi/actions/hooks/usePersistEvolutionProgress.ts b/src/blobbi/actions/hooks/usePersistEvolutionProgress.ts index f8aa1557..68bf79a3 100644 --- a/src/blobbi/actions/hooks/usePersistEvolutionProgress.ts +++ b/src/blobbi/actions/hooks/usePersistEvolutionProgress.ts @@ -75,6 +75,11 @@ export function usePersistEvolutionProgress( 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_BLOBBI_STATE, content,