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:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user