Merge branch 'fix/blobbi-progression-task-persistence' into 'main'

Move Blobbi progression missions from kind 11125 to kind 31124

Closes #239

See merge request soapbox-pub/ditto!193
This commit is contained in:
Chad Curtis
2026-04-21 22:29:05 +00:00
15 changed files with 507 additions and 276 deletions
@@ -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,
});
+38 -33
View File
@@ -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,
@@ -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',
@@ -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,
});
+20 -30
View File
@@ -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,
+59 -43
View File
@@ -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<string | null>(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<string | null>(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<string, number> = useMemo(() => {
if (!data) return {} as Record<string, number>;
return {
@@ -158,24 +181,23 @@ export function useEvolveTasks(
const lastBackfilledDataRef = useRef<typeof data>(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,
};
}
+64 -39
View File
@@ -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<string | null>(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<string | null>(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<string, number> = useMemo(() => {
if (!data) return {} as Record<string, number>;
return {
@@ -172,33 +202,28 @@ export function useHatchTasks(
const lastBackfilledDataRef = useRef<typeof data>(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;
@@ -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]);
}
+118 -53
View File
@@ -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<string, MissionsContent>();
const dailyStore = new Map<string, MissionsContent>();
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<string, unknown>): 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<string, Mission[]>();
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;
+13 -18
View File
@@ -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) => {
@@ -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,
});
+8
View File
@@ -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,
};
}
+69 -10
View File
@@ -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<string, unknown> = {};
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<string, unknown>;
if ('evolution' in m) {
delete m.evolution;
}
}
return JSON.stringify(merged);
}
// ─── Internal Helpers ────────────────────────────────────────────────────────
@@ -120,11 +179,11 @@ function parseMissionsContent(raw: Record<string, unknown>): 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[] = [];
@@ -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),
+5 -5
View File
@@ -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);