diff --git a/src/blobbi/actions/components/BlobbiMissionsModal.tsx b/src/blobbi/actions/components/BlobbiMissionsModal.tsx index 4ca6a34a..8eed24d0 100644 --- a/src/blobbi/actions/components/BlobbiMissionsModal.tsx +++ b/src/blobbi/actions/components/BlobbiMissionsModal.tsx @@ -1,19 +1,39 @@ // src/blobbi/actions/components/BlobbiMissionsModal.tsx /** - * Missions modal for Blobbi. - * - * Shows: - * - Daily missions (always visible, separate reward system) - * - Incubation tasks when the current Blobbi is incubating (egg stage) - * - Evolve tasks when evolving (baby stage) + * Missions modal for Blobbi — card-grid quest board. + * + * Layout: + * 1. Sticky header with title, subtitle, legend help button, close + * 2. Current Focus section (hatch / evolve) — collapsible, default open + * 3. Daily Bounties section — collapsible, default open + * 4. Settings row — low emphasis toggle (not collapsible) + * + * Both main sections use lightweight Radix Collapsible wrappers. + * Collapsed headers still show summary info (progress / coins). */ -import { Target, Loader2, XCircle, AlertTriangle, Calendar, Coins, X, ChevronDown } from 'lucide-react'; +import { + Loader2, + XCircle, + AlertTriangle, + Coins, + X, + Eye, + Scroll, + Compass, + HelpCircle, + ChevronDown, +} from 'lucide-react'; import { formatCompactNumber, cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Dialog, DialogContent, DialogClose } from '@/components/ui/dialog'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { AlertDialog, AlertDialogAction, @@ -24,7 +44,6 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { useState } from 'react'; import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi'; @@ -42,36 +61,86 @@ import { useRerollMission } from '../hooks/useRerollMission'; interface BlobbiMissionsModalProps { open: boolean; onOpenChange: (open: boolean) => void; - /** Current companion being viewed */ companion: BlobbiCompanion; - /** Current Blobbonaut profile (required for coin updates) */ profile: BlobbonautProfile | null; - /** Callback to update profile in query cache after claiming */ updateProfileEvent: (event: NostrEvent) => void; - /** Hatch tasks result from useHatchTasks */ hatchTasks: HatchTasksResult; - /** Evolve tasks result from useEvolveTasks */ evolveTasks: EvolveTasksResult; - /** Called when user clicks "Create Post" action in tasks */ onOpenPostModal: () => void; - /** Called when all hatch tasks are complete and user clicks "Hatch" */ onHatch: () => void; - /** Whether hatching is in progress */ isHatching: boolean; - /** Called when all evolve tasks are complete and user clicks "Evolve" */ onEvolve: () => void; - /** Whether evolving is in progress */ isEvolving: boolean; - /** Called when user confirms stopping incubation */ onStopIncubation: () => Promise; - /** Whether stop incubation is in progress */ isStoppingIncubation: boolean; - /** Called when user confirms stopping evolution */ onStopEvolution: () => Promise; - /** Whether stop evolution is in progress */ isStoppingEvolution: boolean; - /** Available Blobbi stages across all user's companions (for mission filtering) */ availableStages?: ('egg' | 'baby' | 'adult')[]; + showMissionCard?: boolean; + onToggleMissionCard?: (visible: boolean) => void; +} + +// ─── Section Chevron ───────────────────────────────────────────────────────── + +function SectionChevron({ open }: { open: boolean }) { + return ( + + ); +} + +// ─── Mission Type Legend ────────────────────────────────────────────────────── + +function MissionTypeLegend() { + return ( + + + + + +

Mission Types

+
+
+
+ +
+
+

Daily Bounty

+

Resets every day

+
+
+
+
+ 🥚 +
+
+

Hatch Task

+

Egg progression

+
+
+
+
+ 🐣 +
+
+

Evolve Task

+

Baby progression

+
+
+
+
+
+ ); } // ─── Daily Missions Section ─────────────────────────────────────────────────── @@ -79,14 +148,20 @@ interface BlobbiMissionsModalProps { interface DailyMissionsSectionProps { profile: BlobbonautProfile | null; updateProfileEvent: (event: NostrEvent) => void; - /** Available Blobbi stages the user has */ availableStages?: ('egg' | 'baby' | 'adult')[]; disabled?: boolean; defaultOpen?: boolean; } -function DailyMissionsSection({ profile, updateProfileEvent, availableStages, disabled, defaultOpen = true }: DailyMissionsSectionProps) { +function DailyMissionsSection({ + profile, + updateProfileEvent, + availableStages, + disabled, + defaultOpen = true, +}: DailyMissionsSectionProps) { const [isOpen, setIsOpen] = useState(defaultOpen); + const { missions, todayClaimedReward, @@ -100,58 +175,56 @@ function DailyMissionsSection({ profile, updateProfileEvent, availableStages, di const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward( profile, - updateProfileEvent + updateProfileEvent, ); const { mutate: rerollMission, isPending: isRerolling } = useRerollMission(); - const handleClaimReward = (missionId: string) => { - claimReward({ missionId }); - }; - - const handleRerollMission = (missionId: string) => { - rerollMission({ missionId, availableStages }); - }; + const claimableCount = missions.filter((m) => m.completed && !m.claimed).length; return ( - - {/* Section header - Clickable */} + + {/* Section header — tappable */} -
+
- -

Daily Missions

+ +

Daily Bounties

-
- - + {/* Summary pill — always visible */} +
+ + {formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)} + {claimableCount > 0 && ( + + {claimableCount} + + )}
- +
- {/* Mission list */} - - + +
+ claimReward({ missionId: id })} + onRerollMission={(id) => rerollMission({ missionId: id, availableStages })} + todayCoins={todayClaimedReward} + disabled={disabled || isClaiming || isRerolling} + bonusAvailable={bonusAvailable} + bonusClaimed={bonusClaimed} + bonusReward={bonusReward} + noMissionsAvailable={noMissionsAvailable} + rerollsRemaining={rerollsRemaining} + isRerolling={isRerolling} + /> +
); @@ -224,9 +297,9 @@ function StopConfirmationDialog({ ); } -// ─── Process Content (Incubation or Evolution) ──────────────────────────────── +// ─── Current Focus Section (Hatch / Evolve) ────────────────────────────────── -interface ProcessContentProps { +interface CurrentFocusSectionProps { companion: BlobbiCompanion; tasks: HatchTasksResult | EvolveTasksResult; processType: 'incubation' | 'evolution'; @@ -238,7 +311,7 @@ interface ProcessContentProps { defaultOpen?: boolean; } -function ProcessContent({ +function CurrentFocusSection({ companion, tasks, processType, @@ -248,93 +321,98 @@ function ProcessContent({ onStop, isStopping, defaultOpen = true, -}: ProcessContentProps) { +}: CurrentFocusSectionProps) { const [isOpen, setIsOpen] = useState(defaultOpen); const [showStopConfirmation, setShowStopConfirmation] = useState(false); const isIncubation = processType === 'incubation'; - const emoji = isIncubation ? '🥚' : '🐣'; const title = isIncubation ? 'Hatch Tasks' : 'Evolve Tasks'; - const description = isIncubation - ? 'Complete these tasks to hatch your Blobbi' - : 'Complete these tasks to evolve your Blobbi'; const completeLabel = isIncubation ? 'Hatch Your Blobbi!' : 'Evolve Your Blobbi!'; const completingLabel = isIncubation ? 'Hatching...' : 'Evolving...'; const completeEmoji = isIncubation ? '🐣' : '✨'; const stopLabel = isIncubation ? 'Stop Incubation' : 'Stop Evolution'; + const badgeLabel = isIncubation ? 'Hatch' : 'Evolve'; + const category = isIncubation ? ('hatch' as const) : ('evolve' as const); - const completedCount = tasks.tasks.filter(t => t.completed).length; + const completedCount = tasks.tasks.filter((t) => t.completed).length; const totalTasks = tasks.tasks.length; return ( - - {/* Section header - Clickable */} + + {/* Section header — tappable */} -
+
- {emoji} -

{title}

+ + {badgeLabel} + + {title}
- - {completedCount}/{totalTasks} + + {completedCount} / {totalTasks} - +
- {/* Tasks content */} - - {/* Tasks Panel */} - + +
+ {/* Task card grid */} + - {/* Stop Process Button */} -
- + {/* Stop process — low emphasis */} +
+ +
- {/* Stop Confirmation Dialog */} + +

No active progression right now

+
+ ); +} + // ─── Main Modal ─────────────────────────────────────────────────────────────── export function BlobbiMissionsModal({ @@ -367,54 +456,46 @@ export function BlobbiMissionsModal({ onStopEvolution, isStoppingEvolution, availableStages, + showMissionCard, + onToggleMissionCard, }: BlobbiMissionsModalProps) { const isIncubating = companion.state === 'incubating'; const isEvolvingState = companion.state === 'evolving'; const isEgg = companion.stage === 'egg'; const isBaby = companion.stage === 'baby'; - // Check if there's an active hatch/evolve process const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby); const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution; return ( - {/* Header - Sticky */} - -
-
- - - Missions - - - Complete missions to earn rewards for {companion.name} - + {/* ── Sticky Header ── */} +
+
+
+

Missions

+

+ Quests & bounties for {companion.name} +

+
+
+ + + + Close +
- - - Close -
- +
- {/* Content - Scrollable */} -
- {/* Daily Missions Section - Always visible, expanded by default */} - - - {/* Hatch/Evolve Process Section - Only when active, expanded by default */} - {hasActiveProcess && ( + {/* ── Scrollable Content ── */} +
+ {/* 1. Current Focus */} + {hasActiveProcess ? ( <> {isIncubating && isEgg ? ( - ) : isEvolvingState && isBaby ? ( - ) : null} + ) : ( + + )} + + {/* Divider */} +
+ + {/* 2. Daily Bounties */} + + + {/* 3. Settings */} + {onToggleMissionCard !== undefined && showMissionCard !== undefined && ( + <> +
+
+ + +
+ )}
diff --git a/src/blobbi/actions/components/BlobbiPostModal.tsx b/src/blobbi/actions/components/BlobbiPostModal.tsx index f5ba5460..1366319d 100644 --- a/src/blobbi/actions/components/BlobbiPostModal.tsx +++ b/src/blobbi/actions/components/BlobbiPostModal.tsx @@ -30,6 +30,7 @@ import { toast } from '@/hooks/useToast'; import { BLOBBI_POST_REQUIRED_HASHTAGS, + buildHatchPhrase, } from '../hooks/useHatchTasks'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -49,33 +50,13 @@ interface BlobbiPostModalProps { // ─── Helpers ────────────────────────────────────────────────────────────────── -/** - * Sanitize a name into a valid hashtag format. - * - Removes special characters - * - Replaces spaces with nothing (camelCase-like) - * - Ensures lowercase - * - Handles edge cases - */ -function sanitizeToHashtag(name: string): string { - return name - .toLowerCase() - // Remove emojis and special characters, keep letters, numbers, underscores - .replace(/[^\p{L}\p{N}_]/gu, '') - // Ensure it starts with a letter (prepend 'blobbi' if it starts with number) - .replace(/^(\d)/, 'blobbi$1') - // Limit length - .slice(0, 30) - // Fallback if empty - || 'myblobbi'; -} - /** * Build the required prefix text based on process type. */ function buildPrefix(process: BlobbiPostProcess): string { return process === 'evolve' - ? 'Hello Nostr! Posting to evolve' - : 'Hello Nostr! Posting to hatch'; + ? 'Posting to evolve' + : 'Posting to hatch'; } // ─── Main Component ─────────────────────────────────────────────────────────── @@ -91,20 +72,19 @@ export function BlobbiPostModal({ const { mutateAsync: createEvent, isPending } = useNostrPublish(); // Compute the required elements based on props - const blobbiHashtag = useMemo(() => sanitizeToHashtag(blobbiName), [blobbiName]); const prefix = useMemo(() => buildPrefix(process), [process]); + const capitalizedName = useMemo(() => blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1), [blobbiName]); - // All required hashtags including the Blobbi name (first) - const allRequiredHashtags = useMemo(() => - [blobbiHashtag, ...BLOBBI_POST_REQUIRED_HASHTAGS], - [blobbiHashtag] + // The required phrase that must appear in the post + const requiredPhrase = useMemo(() => + process === 'hatch' + ? buildHatchPhrase(blobbiName) + : `${prefix} ${capitalizedName} #blobbi`, + [process, blobbiName, prefix, capitalizedName] ); - // Build default content - const defaultContent = useMemo(() => - `${prefix} #${allRequiredHashtags.join(' #')}`, - [prefix, allRequiredHashtags] - ); + // Build default content (the phrase itself is enough) + const defaultContent = useMemo(() => requiredPhrase, [requiredPhrase]); const [content, setContent] = useState(defaultContent); const [validationError, setValidationError] = useState(null); @@ -118,24 +98,14 @@ export function BlobbiPostModal({ }, [open, defaultContent]); /** - * Validate that the content still contains the required prefix and hashtags. + * Validate that the content contains the required phrase. */ const validateContent = useCallback((text: string): string | null => { - // Check prefix - if (!text.startsWith(prefix)) { - return 'The post must start with the required text'; + if (!text.includes(requiredPhrase)) { + return `The post must contain: "${requiredPhrase}"`; } - - // Check all required hashtags are present (including Blobbi name) - const lowerText = text.toLowerCase(); - for (const tag of allRequiredHashtags) { - if (!lowerText.includes(`#${tag.toLowerCase()}`)) { - return `Missing required hashtag: #${tag}`; - } - } - return null; - }, [prefix, allRequiredHashtags]); + }, [requiredPhrase]); /** * Handle content change with validation. @@ -180,21 +150,26 @@ export function BlobbiPostModal({ } try { - // Build tags for the post + // Build tags for the post: extract all hashtags from content const tags: string[][] = []; + const seen = new Set(); - // Add all required hashtags as 't' tags - for (const hashtag of allRequiredHashtags) { - tags.push(['t', hashtag.toLowerCase()]); + // Always include BLOBBI_POST_REQUIRED_HASHTAGS as t tags + for (const hashtag of BLOBBI_POST_REQUIRED_HASHTAGS) { + const lower = hashtag.toLowerCase(); + if (!seen.has(lower)) { + tags.push(['t', lower]); + seen.add(lower); + } } - // Extract any additional hashtags the user added - const additionalHashtags = content.match(/#(\w+)/g) || []; - const requiredLower = allRequiredHashtags.map(t => t.toLowerCase()); - for (const tag of additionalHashtags) { + // Extract any additional hashtags from the content + const contentHashtags = content.match(/#(\w+)/g) || []; + for (const tag of contentHashtags) { const tagValue = tag.slice(1).toLowerCase(); - if (!requiredLower.includes(tagValue)) { + if (!seen.has(tagValue)) { tags.push(['t', tagValue]); + seen.add(tagValue); } } @@ -220,7 +195,7 @@ export function BlobbiPostModal({ variant: 'destructive', }); } - }, [user, content, validateContent, createEvent, onOpenChange, onSuccess, allRequiredHashtags, process]); + }, [user, content, validateContent, createEvent, onOpenChange, onSuccess, process]); const canPost = !validationError && content.trim().length > 0; @@ -282,13 +257,9 @@ export function BlobbiPostModal({ {/* Preview of required content */}
-

Required content:

-

- {prefix} - {' '} - {allRequiredHashtags.map(tag => ( - #{tag} - ))} +

Required phrase:

+

+ {requiredPhrase}

diff --git a/src/blobbi/actions/components/DailyMissionsPanel.tsx b/src/blobbi/actions/components/DailyMissionsPanel.tsx index 5a2ee360..0b44dd69 100644 --- a/src/blobbi/actions/components/DailyMissionsPanel.tsx +++ b/src/blobbi/actions/components/DailyMissionsPanel.tsx @@ -1,285 +1,164 @@ /** - * DailyMissionsPanel - UI component for displaying daily missions - * - * Shows: - * - Daily mission list with progress bars - * - Completion state - * - Claim buttons for completed missions - * - Coin rewards - * - Bonus mission after completing all regular missions - * - Empty state when no missions available (egg-only users) - * - Reroll button to replace missions (max 3/day) + * DailyMissionsPanel — card-grid layout for daily bounties. + * + * Each mission is a compact card in a 2-col grid. + * Tapping a card expands it to show progress, claim button, and reroll. + * Only one card expanded at a time. */ -import { Check, Coins, Gift, Sparkles, Egg, Trophy, RefreshCw } from 'lucide-react'; +import { useState } from 'react'; +import { + Check, + Coins, + Gift, + Sparkles, + Egg, + Trophy, + RefreshCw, + Heart, + Utensils, + Droplets, + Moon, + Camera, + Mic, + Music, + Pill, + CircleDot, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Progress } from '@/components/ui/progress'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { cn, formatCompactNumber } from '@/lib/utils'; -import type { DailyMission } from '../lib/daily-missions'; +import type { DailyMission, DailyMissionAction } from '../lib/daily-missions'; import { BONUS_MISSION_ID } from '../hooks/useClaimMissionReward'; +import { + ExpandableMissionCard, + MissionDescription, + MissionProgress, +} from './ExpandableMissionCard'; // ─── Types ──────────────────────────────────────────────────────────────────── interface DailyMissionsPanelProps { - /** The daily missions to display */ missions: DailyMission[]; - /** Callback when claiming a mission reward */ onClaimReward: (missionId: string) => void; - /** Callback when rerolling a mission */ onRerollMission?: (missionId: string) => void; - /** Total coins earned today */ todayCoins: number; - /** Whether claiming is disabled (e.g., during another operation) */ disabled?: boolean; - /** Whether the bonus mission is available */ bonusAvailable?: boolean; - /** Whether the bonus mission has been claimed */ bonusClaimed?: boolean; - /** Bonus mission reward amount */ bonusReward?: number; - /** Whether user has no eligible missions (e.g., only eggs) */ noMissionsAvailable?: boolean; - /** Number of rerolls remaining today */ rerollsRemaining?: number; - /** Whether a reroll is currently in progress */ isRerolling?: boolean; } -// ─── Mission Item ───────────────────────────────────────────────────────────── +// ─── Daily Mission Icon Mapping ─────────────────────────────────────────────── -interface MissionItemProps { - mission: DailyMission; - onClaim: () => void; - onReroll?: () => void; - disabled?: boolean; - canReroll?: boolean; - isRerolling?: boolean; +function DailyMissionIcon({ action }: { action: DailyMissionAction }) { + const cls = 'size-5'; + switch (action) { + case 'interact': + return ; + case 'feed': + return ; + case 'clean': + return ; + case 'sleep': + return ; + case 'take_photo': + return ; + case 'sing': + return ; + case 'play_music': + return ; + case 'medicine': + return ; + default: + return ; + } } -function MissionItem({ mission, onClaim, onReroll, disabled, canReroll = false, isRerolling = false }: MissionItemProps) { - const progressPercent = (mission.currentCount / mission.requiredCount) * 100; - const canClaim = mission.completed && !mission.claimed; - - // Can only reroll if: not completed, not claimed, has reroll callback, and has rerolls remaining - const showRerollButton = onReroll && !mission.completed && !mission.claimed && canReroll; +// ─── Bonus Card ─────────────────────────────────────────────────────────────── - return ( -
- {/* Top right area: Claimed badge OR Reroll button */} -
- {mission.claimed ? ( -
- - Claimed -
- ) : showRerollButton ? ( - - - - - - -

Replace this mission

-
-
-
- ) : null} -
- - {/* Mission content */} -
- {/* Title and description */} -
-

{mission.title}

-

- {mission.description} -

-
- - {/* Progress bar */} -
-
- - {mission.currentCount} / {mission.requiredCount} - - - - {formatCompactNumber(mission.reward)} - -
- div]:bg-green-500' - )} - /> -
- - {/* Claim button */} - {canClaim && ( - - )} -
-
- ); -} - -// ─── Bonus Mission Item ─────────────────────────────────────────────────────── - -interface BonusMissionItemProps { +interface BonusCardProps { isAvailable: boolean; isClaimed: boolean; reward: number; onClaim: () => void; disabled?: boolean; + isExpanded: boolean; + onToggle: (id: string) => void; } -function BonusMissionItem({ isAvailable, isClaimed, reward, onClaim, disabled }: BonusMissionItemProps) { +function BonusCard({ isAvailable, isClaimed, reward, onClaim, disabled, isExpanded, onToggle }: BonusCardProps) { + const progress = isClaimed ? 1 : isAvailable ? 1 : 0; + return ( -
} + title="Daily Champion" + completed={isClaimed} + progress={progress} + isExpanded={isExpanded} + onToggle={onToggle} > - {/* Claimed badge */} - {isClaimed && ( -
-
- - Claimed -
-
- )} + + {isAvailable || isClaimed + ? 'Bonus reward for completing all daily missions!' + : 'Complete all missions to unlock this bonus'} + - {/* Mission content */} -
- {/* Title and description */} -
-
- -

Daily Champion

-
-

- {isAvailable || isClaimed - ? 'Bonus reward for completing all daily missions!' - : 'Complete all missions above to unlock this bonus'} -

-
- - {/* Reward display */} -
- - Bonus Reward - - - - +{formatCompactNumber(reward)} - -
- - {/* Claim button */} - {isAvailable && !isClaimed && ( - - )} +
+ + +{formatCompactNumber(reward)}
-
+ + {isAvailable && !isClaimed && ( + + )} + ); } -// ─── No Missions Available State ────────────────────────────────────────────── +// ─── Empty / Done States ────────────────────────────────────────────────────── function NoMissionsState() { return ( -
-
- -
-
-

Hatch Your Blobbi First

-

- Daily missions will be available once you have -
- a hatched Blobbi to interact with! +

+ +
+

Hatch your Blobbi first

+

+ Daily missions unlock after hatching

); } -// ─── All Claimed State ──────────────────────────────────────────────────────── - -interface AllClaimedStateProps { - todayCoins: number; -} - -function AllClaimedState({ todayCoins }: AllClaimedStateProps) { +function AllClaimedState({ todayCoins }: { todayCoins: number }) { return ( -
-
- -
-
-

All Done for Today!

-

- You earned {formatCompactNumber(todayCoins)} coins today. -
- Come back tomorrow for new missions! +

+ +
+

All done for today

+

+ Earned{' '} + + {formatCompactNumber(todayCoins)} coins + {' '} + — come back tomorrow!

@@ -288,20 +167,17 @@ function AllClaimedState({ todayCoins }: AllClaimedStateProps) { // ─── Reroll Counter ─────────────────────────────────────────────────────────── -interface RerollCounterProps { - remaining: number; -} +function RerollCounter({ remaining }: { remaining: number }) { + const text = + remaining === 0 + ? 'No rerolls left' + : remaining === 1 + ? '1 reroll left' + : `${remaining} rerolls left`; -function RerollCounter({ remaining }: RerollCounterProps) { - const text = remaining === 0 - ? 'No rerolls left' - : remaining === 1 - ? '1 reroll left' - : `${remaining} rerolls left`; - return ( -
- +
+ {text}
); @@ -322,48 +198,121 @@ export function DailyMissionsPanel({ rerollsRemaining = 0, isRerolling = false, }: DailyMissionsPanelProps) { - // Show empty state if user has no eligible missions (e.g., only eggs) - if (noMissionsAvailable) { - return ; - } + const [expandedId, setExpandedId] = useState(null); + + if (noMissionsAvailable) return ; const allRegularClaimed = missions.every((m) => m.claimed); const allDone = allRegularClaimed && bonusClaimed; - // Show "all done" state only when everything including bonus is claimed - if (allDone) { - return ; - } + if (allDone) return ; const canReroll = rerollsRemaining > 0 && !!onRerollMission; + const handleToggle = (id: string) => { + setExpandedId((prev) => (prev === id ? null : id)); + }; + return ( -
- {/* Reroll counter - only show if reroll functionality is available */} - {onRerollMission && ( - - )} - - {/* Regular missions */} - {missions.map((mission) => ( - onClaimReward(mission.id)} - onReroll={onRerollMission ? () => onRerollMission(mission.id) : undefined} - disabled={disabled} - canReroll={canReroll} - isRerolling={isRerolling} - /> - ))} - - {/* Bonus mission - always visible */} - + {/* Reroll counter */} + {onRerollMission && } + + {/* Regular mission cards */} + {missions.map((mission) => { + const progress = mission.requiredCount > 0 ? mission.currentCount / mission.requiredCount : 0; + const canClaim = mission.completed && !mission.claimed; + const showReroll = onRerollMission && !mission.completed && !mission.claimed && canReroll; + + return ( + } + title={mission.title} + completed={mission.claimed} + progress={Math.min(progress, 1)} + isExpanded={expandedId === mission.id} + onToggle={handleToggle} + > + {/* Description */} + {mission.description} + + {/* Progress */} + {!mission.claimed && ( + + )} + + {/* Reward + reroll row */} +
+ + + {formatCompactNumber(mission.reward)} + + + {showReroll && ( + + + + + + +

Replace mission

+
+
+
+ )} + + {mission.claimed && ( + + + Done + + )} +
+ + {/* Claim button */} + {canClaim && ( + + )} +
+ ); + })} + + {/* Bonus card */} + onClaimReward(BONUS_MISSION_ID)} disabled={disabled} + isExpanded={expandedId === 'bonus'} + onToggle={handleToggle} />
); diff --git a/src/blobbi/actions/components/ExpandableMissionCard.tsx b/src/blobbi/actions/components/ExpandableMissionCard.tsx new file mode 100644 index 00000000..93a58b32 --- /dev/null +++ b/src/blobbi/actions/components/ExpandableMissionCard.tsx @@ -0,0 +1,250 @@ +// src/blobbi/actions/components/ExpandableMissionCard.tsx + +/** + * Expandable mission card for the quest-board grid. + * + * Collapsed: compact square-ish card showing icon, title, and a tiny + * progress ring / checkmark. + * Expanded: full-width row that reveals description, progress bar, + * action link, claim button, dynamic hints, etc. + * + * Only one card is expanded at a time per section (controlled by parent). + */ + +import type { ReactNode } from 'react'; +import { Check, ChevronRight, ExternalLink, AlertCircle } from 'lucide-react'; +import { Progress } from '@/components/ui/progress'; +import { cn } from '@/lib/utils'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type MissionCategory = 'daily' | 'hatch' | 'evolve'; + +export interface ExpandableMissionCardProps { + /** Unique id used to track which card is expanded */ + id: string; + /** Mission category for visual styling */ + category: MissionCategory; + /** Icon rendered in the compact card (ReactNode — usually a lucide icon or emoji span) */ + icon: ReactNode; + /** Short title */ + title: string; + /** Whether the mission is complete */ + completed: boolean; + /** Progress fraction 0-1 (used for the tiny ring in compact mode) */ + progress: number; + /** Whether this card is currently expanded */ + isExpanded: boolean; + /** Parent calls this to toggle expansion */ + onToggle: (id: string) => void; + /** Content rendered only when expanded */ + children: ReactNode; + /** Optional extra className on the outer wrapper */ + className?: string; +} + +// ─── Tiny Progress Ring ─────────────────────────────────────────────────────── + +function ProgressRing({ progress, completed, category }: { progress: number; completed: boolean; category: MissionCategory }) { + const size = 28; + const stroke = 2.5; + const radius = (size - stroke) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference - progress * circumference; + + if (completed) { + return ( +
+ +
+ ); + } + + const ringColor = + category === 'hatch' + ? 'text-sky-500' + : category === 'evolve' + ? 'text-violet-500' + : 'text-amber-500'; + + return ( + + + + + ); +} + +// ─── Accent colors per category ─────────────────────────────────────────────── + +const CATEGORY_STYLES: Record = { + daily: { + bg: 'bg-amber-500/[0.06] hover:bg-amber-500/10', + expandedBg: 'bg-amber-500/[0.06]', + border: 'ring-amber-500/20', + }, + hatch: { + bg: 'bg-sky-500/[0.06] hover:bg-sky-500/10', + expandedBg: 'bg-sky-500/[0.06]', + border: 'ring-sky-500/20', + }, + evolve: { + bg: 'bg-violet-500/[0.06] hover:bg-violet-500/10', + expandedBg: 'bg-violet-500/[0.06]', + border: 'ring-violet-500/20', + }, +}; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ExpandableMissionCard({ + id, + category, + icon, + title, + completed, + progress, + isExpanded, + onToggle, + children, + className, +}: ExpandableMissionCardProps) { + const styles = CATEGORY_STYLES[category]; + + // ── Collapsed card ── + if (!isExpanded) { + return ( + + ); + } + + // ── Expanded card (spans full row) ── + return ( +
+ {/* Compact header — click to collapse */} + + + {/* Expanded details */} +
+ {children} +
+
+ ); +} + +// ─── Shared detail sub-components ───────────────────────────────────────────── + +/** Description text */ +export function MissionDescription({ children }: { children: ReactNode }) { + return

{children}

; +} + +/** Progress bar with fraction label */ +export function MissionProgress({ current, required, completed }: { current: number; required: number; completed: boolean }) { + const pct = required > 0 ? Math.round((current / required) * 100) : 0; + return ( +
+
+ {current} / {required} + {pct}% +
+ div]:bg-emerald-500')} /> +
+ ); +} + +/** Inline action link (navigate, external, modal) */ +export function MissionAction({ + label, + type, + onClick, +}: { + label: string; + type: 'navigate' | 'external_link' | 'open_modal'; + onClick: () => void; +}) { + return ( + + ); +} + +/** Dynamic / live task hint */ +export function DynamicHint({ current, required }: { current: number; required: number }) { + return ( +
+ + Lowest stat: {current}% (need {required}%+) +
+ ); +} diff --git a/src/blobbi/actions/components/TasksPanel.tsx b/src/blobbi/actions/components/TasksPanel.tsx index 84146737..1f820bdb 100644 --- a/src/blobbi/actions/components/TasksPanel.tsx +++ b/src/blobbi/actions/components/TasksPanel.tsx @@ -1,22 +1,38 @@ // src/blobbi/actions/components/TasksPanel.tsx /** - * Generic UI component for displaying task progress. - * Shows a list of tasks with progress indicators and action buttons. - * Used for both hatch and evolve tasks. + * Card-grid presentation for hatch / evolve tasks. + * + * Each task is a compact card in a 2-column grid. + * Tapping a card expands it inline (full row) to reveal details. + * Only one card is expanded at a time. */ -import { ExternalLink, Check, Loader2, ChevronRight, AlertCircle } from 'lucide-react'; +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { + Palette, + Droplets, + MessageSquare, + Heart, + UserPen, + Activity, + Loader2, + HelpCircle, +} from 'lucide-react'; import { openUrl } from '@/lib/downloadFile'; import { Button } from '@/components/ui/button'; -import { Progress } from '@/components/ui/progress'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { cn } from '@/lib/utils'; import type { HatchTask } from '../hooks/useHatchTasks'; +import type { MissionCategory } from './ExpandableMissionCard'; +import { + ExpandableMissionCard, + MissionDescription, + MissionProgress, + MissionAction, + DynamicHint, +} from './ExpandableMissionCard'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -24,149 +40,38 @@ interface TasksPanelProps { tasks: HatchTask[]; allCompleted: boolean; isLoading: boolean; - /** Called when user clicks "Create Post" action */ onOpenPostModal: () => void; - /** Called when all tasks are complete and user clicks the complete button */ onComplete: () => void; - /** Whether completion is in progress */ isCompleting?: boolean; - /** Emoji to show in header */ - emoji: string; - /** Title for the tasks panel */ - title: string; - /** Description for the tasks panel */ - description: string; - /** Label for the complete button */ completeLabel: string; - /** Label while completing */ completingLabel: string; - /** Emoji for complete button */ completeEmoji: string; + /** Mission category for styling the cards */ + category?: MissionCategory; } -// ─── Task Row Component ─────────────────────────────────────────────────────── +// ─── Task Icon Mapping ──────────────────────────────────────────────────────── -interface TaskRowProps { - task: HatchTask; - onOpenPostModal: () => void; -} +/** Map task ids to lucide icons. Falls back to a generic icon. */ +function TaskIcon({ taskId }: { taskId: string }) { + const iconClass = 'size-5'; -function TaskRow({ task, onOpenPostModal }: TaskRowProps) { - const navigate = useNavigate(); - const isDynamic = task.type === 'dynamic'; - - const handleAction = () => { - if (!task.action || !task.actionTarget) return; - - switch (task.action) { - case 'navigate': - navigate(task.actionTarget); - break; - case 'external_link': - openUrl(task.actionTarget); - break; - case 'open_modal': - if (task.actionTarget === 'blobbi_post') { - onOpenPostModal(); - } - break; - } - }; - - const progress = task.required > 1 - ? Math.round((task.current / task.required) * 100) - : task.completed ? 100 : 0; - - return ( -
- {/* Top row on mobile: Status + Task info */} -
- {/* Status indicator */} -
- {task.completed ? ( - - ) : isDynamic ? ( - - ) : task.required > 1 ? ( - {task.current}/{task.required} - ) : ( - - )} -
- - {/* Task info */} -
-
-

- {task.name} -

- {task.completed && ( - - Complete - - )} - {isDynamic && !task.completed && ( - - Live - - )} -
-

- {task.description} -

- - {/* Progress bar for multi-step tasks (not for dynamic stat tasks) */} - {task.required > 1 && !task.completed && !isDynamic && ( - - )} - - {/* Dynamic task hint */} - {isDynamic && !task.completed && ( -

- Lowest stat: {task.current}% (need {task.required}%+) -

- )} -
-
- - {/* Action button - full width on mobile when present */} - {task.action && task.actionLabel && !task.completed && ( - - )} -
- ); + switch (taskId) { + case 'create_themes': + return ; + case 'color_moments': + return ; + case 'create_posts': + return ; + case 'interactions': + return ; + case 'edit_profile': + return ; + case 'maintain_stats': + return ; + default: + return ; + } } // ─── Main Component ─────────────────────────────────────────────────────────── @@ -178,86 +83,113 @@ export function TasksPanel({ onOpenPostModal, onComplete, isCompleting = false, - emoji, - title, - description, completeLabel, completingLabel, completeEmoji, + category = 'hatch', }: TasksPanelProps) { - const completedCount = tasks.filter(t => t.completed).length; - const totalTasks = tasks.length; - const overallProgress = totalTasks > 0 ? Math.round((completedCount / totalTasks) * 100) : 0; - + const [expandedId, setExpandedId] = useState(null); + const navigate = useNavigate(); + + const handleToggle = (id: string) => { + setExpandedId((prev) => (prev === id ? null : id)); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + return ( - - -
-
- - {emoji} - {title} - - - {description} - -
- - {completedCount}/{totalTasks} - -
- - {/* Overall progress */} -
-
- Overall progress - {overallProgress}% -
- -
-
- - - {isLoading ? ( -
- -
- ) : ( - <> - {tasks.map(task => ( - - ))} - - {/* Complete button - only visible when all tasks complete */} - {allCompleted && ( -
- -
- )} - - )} -
-
+
+ {/* Card grid */} +
+ {tasks.map((task) => { + const isDynamic = task.type === 'dynamic'; + const progress = + task.required > 0 ? task.current / task.required : task.completed ? 1 : 0; + + const handleAction = () => { + if (!task.action || !task.actionTarget) return; + switch (task.action) { + case 'navigate': + navigate(task.actionTarget); + break; + case 'external_link': + openUrl(task.actionTarget); + break; + case 'open_modal': + if (task.actionTarget === 'blobbi_post') onOpenPostModal(); + break; + } + }; + + return ( + } + title={task.name} + completed={task.completed} + progress={Math.min(progress, 1)} + isExpanded={expandedId === task.id} + onToggle={handleToggle} + > + {/* Expanded content */} + {task.description} + + {/* Progress bar for multi-step tasks */} + {task.required > 1 && !isDynamic && ( + + )} + + {/* Dynamic stat hint */} + {isDynamic && !task.completed && ( + + )} + + {/* Action link */} + {task.action && task.actionLabel && !task.completed && ( + + )} + + ); + })} +
+ + {/* CTA button when all tasks are done */} + {allCompleted && ( + + )} +
); } diff --git a/src/blobbi/actions/hooks/useHatchTasks.ts b/src/blobbi/actions/hooks/useHatchTasks.ts index a710e00e..85fcafe2 100644 --- a/src/blobbi/actions/hooks/useHatchTasks.ts +++ b/src/blobbi/actions/hooks/useHatchTasks.ts @@ -34,10 +34,10 @@ export const KIND_SHORT_TEXT_NOTE = 1; export const HATCH_REQUIRED_INTERACTIONS = 7; /** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */ -export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi', 'ditto', 'nostr']; +export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi']; -/** Prefix text for Blobbi hatch post */ -export const BLOBBI_POST_PREFIX = 'Hello Nostr! Posting to hatch'; +/** Prefix text for Blobbi hatch post (the Blobbi name is appended after this) */ +export const BLOBBI_POST_PREFIX = 'Posting to hatch'; // Legacy export for backwards compatibility export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS; @@ -110,16 +110,28 @@ export interface HatchTasksResult { // ─── Helper Functions ───────────────────────────────────────────────────────── +/** + * Build the required phrase for a hatch post. + * Format: "Posting to hatch {CapitalizedName} #blobbi" + */ +export function buildHatchPhrase(blobbiName: string): string { + const capitalized = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1); + return `${BLOBBI_POST_PREFIX} ${capitalized} #blobbi`; +} + /** * Check if a post is a valid Blobbi hatch post. - * Must contain the required prefix and all required hashtags including the Blobbi name. + * The post must contain the required phrase: "Posting to hatch {Name} #blobbi" + * The user may add extra text before or after it. * * @param event - The Nostr event to validate - * @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag) + * @param blobbiName - The Blobbi's name */ export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean { - // Check content starts with prefix - if (!event.content.startsWith(BLOBBI_POST_PREFIX)) { + const phrase = buildHatchPhrase(blobbiName); + + // The phrase must appear somewhere in the content + if (!event.content.includes(phrase)) { return false; } @@ -128,18 +140,12 @@ export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean .filter(tag => tag[0] === 't') .map(tag => tag[1]?.toLowerCase()); - // All required hashtags must be present + // All required hashtags must be present as t tags const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required => hashtags.includes(required.toLowerCase()) ); - if (!hasRequiredHashtags) { - return false; - } - - // Blobbi name hashtag must also be present - const blobbiHashtag = sanitizeToHashtag(blobbiName); - return hashtags.includes(blobbiHashtag); + return hasRequiredHashtags; } // Legacy function name for backwards compatibility diff --git a/src/blobbi/actions/index.ts b/src/blobbi/actions/index.ts index 06e39c0e..6c83e0a2 100644 --- a/src/blobbi/actions/index.ts +++ b/src/blobbi/actions/index.ts @@ -57,6 +57,7 @@ export { sanitizeToHashtag, isValidHatchPost, isValidBlobbiPost, // Legacy export + buildHatchPhrase, KIND_THEME_DEFINITION, KIND_COLOR_MOMENT, HATCH_REQUIRED_INTERACTIONS, diff --git a/src/blobbi/core/lib/blobbi.ts b/src/blobbi/core/lib/blobbi.ts index 2c0a7791..a7a9af99 100644 --- a/src/blobbi/core/lib/blobbi.ts +++ b/src/blobbi/core/lib/blobbi.ts @@ -976,7 +976,8 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und event, d, currentCompanion: getTagValue(tags, 'current_companion'), - onboardingDone: parseBooleanTag(tags, 'onboarding_done', false), + onboardingDone: parseBooleanTag(tags, 'blobbi_onboarding_done', false) + || parseBooleanTag(tags, 'onboarding_done', false), name: getTagValue(tags, 'name'), has: getTagValues(tags, 'has'), coins: parseNumericTag(tags, 'coins') ?? 0, @@ -996,7 +997,7 @@ export function buildBlobbonautTags(pubkey: string): string[][] { return [ ['d', getCanonicalBlobbonautD(pubkey)], ['b', BLOBBI_ECOSYSTEM_NAMESPACE], - ['onboarding_done', 'false'], + ['blobbi_onboarding_done', 'false'], ['pettingLevel', '0'], ]; } @@ -1138,7 +1139,7 @@ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([ * These tags are controlled by the application and may be overwritten. */ export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([ - 'd', 'b', 'name', 'current_companion', 'onboarding_done', 'has', 'storage', + 'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'onboarding_done', 'has', 'storage', // Legacy player progress tags (preserved for compatibility) 'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis', 'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi', @@ -1365,17 +1366,44 @@ export function profileNeedsPettingLevelNormalization(profile: BlobbonautProfile } /** - * Build updated tags for normalizing a profile to include pettingLevel. - * Preserves all existing tags and adds pettingLevel: 0 if missing. + * Check if a profile uses the legacy `onboarding_done` tag instead of the + * new `blobbi_onboarding_done` tag. Returns true if migration is needed. + */ +export function profileNeedsOnboardingTagMigration(profile: BlobbonautProfile): boolean { + const hasNewTag = profile.allTags.some(([name]) => name === 'blobbi_onboarding_done'); + const hasOldTag = profile.allTags.some(([name]) => name === 'onboarding_done'); + // Needs migration if: has old tag but not the new one + return !hasNewTag && hasOldTag; +} + +/** + * Build updated tags for normalizing a profile. + * Handles: + * - Adding pettingLevel: 0 if missing + * - Migrating onboarding_done → blobbi_onboarding_done + * + * Preserves all existing tags except the ones being migrated. */ export function buildNormalizedProfileTags(profile: BlobbonautProfile): string[][] { - if (!profileNeedsPettingLevelNormalization(profile)) { - return profile.allTags; + let tags = profile.allTags; + let changed = false; + + // Normalize pettingLevel + if (profileNeedsPettingLevelNormalization(profile)) { + tags = updateBlobbonautTags(tags, { pettingLevel: '0' }); + changed = true; } - - return updateBlobbonautTags(profile.allTags, { - pettingLevel: '0', - }); + + // Migrate onboarding_done → blobbi_onboarding_done + if (profileNeedsOnboardingTagMigration(profile)) { + const oldValue = tags.find(([name]) => name === 'onboarding_done')?.[1] ?? 'false'; + // Remove old tag, add new tag + tags = tags.filter(([name]) => name !== 'onboarding_done'); + tags = updateBlobbonautTags(tags, { blobbi_onboarding_done: oldValue }); + changed = true; + } + + return changed ? tags : profile.allTags; } // ─── Query Helpers ──────────────────────────────────────────────────────────── diff --git a/src/blobbi/dev/BlobbiDevEditor.tsx b/src/blobbi/dev/BlobbiDevEditor.tsx index 08c067c0..feb23583 100644 --- a/src/blobbi/dev/BlobbiDevEditor.tsx +++ b/src/blobbi/dev/BlobbiDevEditor.tsx @@ -9,7 +9,7 @@ */ import { useState, useCallback, useMemo } from 'react'; -import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun } from 'lucide-react'; +import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun, RefreshCw, SkipForward } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; @@ -27,6 +27,18 @@ import { ADULT_FORMS } from '@/blobbi/adult-blobbi/types/adult.types'; // ─── Types ──────────────────────────────────────────────────────────────────── +/** Tour dev actions for the first-hatch tour */ +interface FirstHatchTourDevActions { + /** Skip the post requirement: advance from show_hatch_card to egg_glowing_waiting_click */ + skipPostRequirement: () => void; + /** Reset the entire first-hatch tour so it can be tested again from scratch */ + resetTour: () => void; + /** Current tour step id, or null if not active */ + currentStepId: string | null; + /** Whether the tour has been completed */ + isCompleted: boolean; +} + interface BlobbiDevEditorProps { /** Whether the editor modal is open */ isOpen: boolean; @@ -38,6 +50,8 @@ interface BlobbiDevEditorProps { onApply: (updates: BlobbiDevUpdates) => Promise; /** Whether an update is in progress */ isUpdating?: boolean; + /** Optional: first-hatch tour dev actions (only passed when tour system is available) */ + tourDevActions?: FirstHatchTourDevActions; } /** Updates that can be applied to a Blobbi */ @@ -170,6 +184,7 @@ export function BlobbiDevEditor({ companion, onApply, isUpdating = false, + tourDevActions, }: BlobbiDevEditorProps) { // ─── Local State ─── // Initialize from companion values @@ -527,8 +542,82 @@ export function BlobbiDevEditor({ onCheckedChange={setBreedingReady} />
-
+
+ + {/* ─── First-Hatch Tour Controls ─── */} + {tourDevActions && ( + <> + +
+
+ + + {tourDevActions.isCompleted + ? 'Completed' + : tourDevActions.currentStepId + ? tourDevActions.currentStepId + : 'Not started'} + +
+

+ Test the first-hatch tour flow without needing to create a real post. +

+
+ {/* A. Skip Post Requirement */} + + + {/* B. Restart First-Hatch Tour */} + + + {/* C. Reset Blobbi to Egg */} + +
+ {companion.stage !== 'egg' && stage === 'egg' && ( +

+ Stage will change to egg. Click "Apply Changes" to publish, then the tour will auto-start. +

+ )} +
+ + )}
diff --git a/src/blobbi/egg/components/EggGraphic.tsx b/src/blobbi/egg/components/EggGraphic.tsx index 6ce21d09..2ba97236 100644 --- a/src/blobbi/egg/components/EggGraphic.tsx +++ b/src/blobbi/egg/components/EggGraphic.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import type { EggVisualBlobbi } from '../types/egg.types'; import { isValidBaseColor, isValidSecondaryColor } from '../lib/blobbi-egg-validation'; import { SpecialMarkRenderer, SpecialMarkFallback } from './SpecialMarkRenderer'; @@ -25,6 +25,29 @@ export interface EggStatusEffects { happy?: boolean; } +/** + * Tour visual states that the egg can display. + * Driven by the tour orchestration layer, not by EggGraphic itself. + * + * - idle: no tour effects + * - show_hatch_card: initial crack visible + auto-wiggle every 2.5s + * - glowing_waiting_click: enhanced glow + auto-wiggle, waiting for tap + * - crack_stage_1: crack expands (click 1) + * - crack_stage_2: crack expands more (click 2) + * - crack_stage_3: final crack (click 3) + * - opening: shell splits open + * - hatching: bright light + reveal + */ +export type EggTourVisualState = + | 'idle' + | 'show_hatch_card' + | 'glowing_waiting_click' + | 'crack_stage_1' + | 'crack_stage_2' + | 'crack_stage_3' + | 'opening' + | 'hatching'; + interface EggGraphicProps { blobbi?: EggVisualBlobbi; // Visual blobbi object for visual properties sizeVariant?: 'tiny' | 'small' | 'medium' | 'large'; // Internal scaling only, NOT layout size @@ -36,6 +59,10 @@ interface EggGraphicProps { forceInlineSvg?: boolean; // New prop to guarantee inline SVG /** Status effects for egg-stage visual feedback */ statusEffects?: EggStatusEffects; + /** Tour visual state - driven externally by the tour orchestration layer */ + tourVisualState?: EggTourVisualState; + /** Callback when the egg is clicked during an interactive tour step */ + onTourEggClick?: () => void; } /** @@ -114,6 +141,8 @@ export const EggGraphic: React.FC = ({ warmth = 50, forceInlineSvg: _forceInlineSvg = false, statusEffects, + tourVisualState = 'idle', + onTourEggClick, }) => { // sizeVariant controls ONLY internal scaling/details, NOT layout dimensions // Parent container controls actual rendered width/height via slot @@ -152,14 +181,62 @@ export const EggGraphic: React.FC = ({ const [isTapWiggling, setIsTapWiggling] = useState(false); const handleEggClick = useCallback(() => { + // Tour interactive steps: forward click to tour controller + if (onTourEggClick && (tourVisualState === 'glowing_waiting_click' || tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2' || tourVisualState === 'crack_stage_3')) { + setIsTapWiggling(true); + onTourEggClick(); + return; + } if (isTapWiggling || cracking) return; // Don't re-trigger during animation or cracking setIsTapWiggling(true); - }, [isTapWiggling, cracking]); + }, [isTapWiggling, cracking, onTourEggClick, tourVisualState]); const handleWiggleEnd = useCallback(() => { setIsTapWiggling(false); }, []); + // Tour: auto-wiggle effect for show_hatch_card and glowing_waiting_click states + const shouldAutoWiggle = tourVisualState === 'show_hatch_card' || tourVisualState === 'glowing_waiting_click'; + const autoWiggleTimerRef = useRef | null>(null); + useEffect(() => { + if (!shouldAutoWiggle) { + if (autoWiggleTimerRef.current) { + clearInterval(autoWiggleTimerRef.current); + autoWiggleTimerRef.current = null; + } + return; + } + // Trigger an immediate wiggle, then repeat every 2.5s + setIsTapWiggling(true); + autoWiggleTimerRef.current = setInterval(() => { + setIsTapWiggling((prev) => { + if (!prev) return true; + return prev; + }); + }, 2500); + return () => { + if (autoWiggleTimerRef.current) { + clearInterval(autoWiggleTimerRef.current); + autoWiggleTimerRef.current = null; + } + }; + }, [shouldAutoWiggle]); + + // Tour: whether the egg should show crack overlay + // The crack stays visible during 'opening' so the shell fades out WITH its cracks intact. + // Only 'idle' and 'hatching' (shell already gone) hide the crack. + const tourShowCrack = tourVisualState !== 'idle' && tourVisualState !== 'hatching'; + + // Tour: crack intensity level (0 = small center crack, 1-3 = progressively expanding) + // Level 0: small central crack (show_hatch_card, glowing_waiting_click) + // Level 1: crack expands left/right with small branches (crack_stage_1) + // Level 2: crack expands further toward edges, more branches (crack_stage_2) + // Level 3: crack reaches near shell edges, about to split (crack_stage_3, opening) + const tourCrackLevel = tourVisualState === 'crack_stage_1' ? 1 + : tourVisualState === 'crack_stage_2' ? 2 + : (tourVisualState === 'crack_stage_3' || tourVisualState === 'opening') ? 3 + : 0; + // Divine color constants const DIVINE_PRIMARY_GREEN = '#55C4A2'; const _DIVINE_HIGHLIGHT_GREEN = '#7AD9B9'; @@ -440,18 +517,32 @@ export const EggGraphic: React.FC = ({ }} > {/* Glow effect based on warmth - relative sizing */} -
+ {(() => { + const isGlowingTour = tourVisualState === 'glowing_waiting_click' + || tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2' + || tourVisualState === 'crack_stage_3'; + const isHatchLight = tourVisualState === 'opening' || tourVisualState === 'hatching'; + return ( +
+ ); + })()} {/* Main egg shape - uses percentage-based sizing */}
= ({ !isTapWiggling && reaction === 'singing' && 'animate-egg-bounce', // Warmth effect only when animated AND warm animated && actualWarmth > 60 && 'animate-egg-warmth', - // Cracking overrides other animations - cracking && 'animate-egg-crack' + // Cracking overrides other animations (legacy prop or tour crack stages) + // During 'opening' the shell runs its own open animation, so suppress the shake + (cracking || (tourCrackLevel >= 1 && tourVisualState !== 'opening')) && 'animate-egg-crack', + // Opening/hatching: fade out the egg shell (crack overlay stays inside and fades with it) + tourVisualState === 'opening' && 'animate-egg-tour-open', + tourVisualState === 'hatching' && 'opacity-0', )} style={{ width: '80%', @@ -480,7 +575,7 @@ export const EggGraphic: React.FC = ({ inset -0.5em -0.5em 1em ${shadow}33, inset 0.5em 0.5em 1em ${highlight}26 `, - filter: cracking ? 'brightness(1.1)' : 'brightness(1)', + filter: (cracking || tourCrackLevel >= 1) ? 'brightness(1.1)' : 'brightness(1)', }} > {/* Highlight on the egg - uses color variants instead of white */} @@ -538,133 +633,181 @@ export const EggGraphic: React.FC = ({ renderLegacySpecialMark(effectiveSpecialMark) ))} - {/* Crack pattern based on docs/aprovado.svg when cracking is true */} - {cracking && ( - - {/* Main horizontal crack (adapted from aprovado.svg) */} - + {/* Crack pattern - stage-specific paths that grow outward from center */} + {(cracking || tourShowCrack) && (() => { + // Legacy cracking shows full crack; tour uses progressive stage-specific paths + const level = cracking ? 3 : tourCrackLevel; + return ( + + {/* + Stage-specific crack paths. + Each level has its OWN distinct paths that expand outward from the egg center. + The crack grows from a small central cluster to full-width fracture. - {/* Secondary cracks (adapted from aprovado.svg) */} - - - - - - - + Viewbox center is roughly (60, 62). + Level 0: tiny central crack (~3-4 small connected segments near center) + Level 1: extends left/right from center, first branches + Level 2: reaches further toward edges, more fracture detail + Level 3: crack reaches near shell edges, dense branching + */} - {/* Additional micro-cracks for detail */} - - - + {/* ── Level 0: Small central crack ── */} + {/* A few short connected segments clustered around the center of the egg */} + {level === 0 && (<> + {/* Main tiny crack: ~15px wide, centered */} + + {/* Tiny upward branch from center */} + + {/* Tiny downward branch */} + + {/* Subtle highlight alongside main crack */} + + )} - {/* Crack highlights for depth (following the main crack pattern) */} - + {/* ── Level 1: Medium crack expanding from center ── */} + {/* Crack extends ~30px wide, first real branches appear */} + {level === 1 && (<> + {/* Main crack: wider than level 0, extends left and right */} + + {/* Highlight */} + + {/* Branch: upward left */} + + {/* Branch: upward from center-right */} + + {/* Branch: downward right */} + + {/* Small micro-branch */} + + )} - {/* Secondary crack highlights */} - - - - )} + {/* ── Level 2: Larger crack reaching toward sides ── */} + {/* Crack extends ~60px wide, more branching detail */} + {level === 2 && (<> + {/* Main crack: extends well toward both sides */} + + {/* Highlight */} + + {/* Branches: left side */} + + + {/* Branches: center */} + + + {/* Branches: right side */} + + + + {/* Micro-cracks */} + + + )} + + {/* ── Level 3: Full crack reaching shell edges ── */} + {/* Crack spans nearly the full width, dense fracture network */} + {level >= 3 && (<> + {/* Main crack: nearly full width of egg */} + + {/* Highlight */} + + {/* Heavy branches: left region */} + + + + {/* Heavy branches: center-left */} + + + + {/* Heavy branches: center */} + + + {/* Heavy branches: center-right */} + + + + {/* Heavy branches: right region */} + + + + {/* Micro-cracks (tertiary detail) */} + + + + + + )} + + ); + })()} {/* Title display for special eggs */} {blobbi?.title && ( diff --git a/src/blobbi/egg/index.ts b/src/blobbi/egg/index.ts index 1bfe3b44..74a3941b 100644 --- a/src/blobbi/egg/index.ts +++ b/src/blobbi/egg/index.ts @@ -12,7 +12,7 @@ import './styles/egg-animations.css'; // Components -export { EggGraphic, type EggReactionState, type EggStatusEffects } from './components/EggGraphic'; +export { EggGraphic, type EggReactionState, type EggStatusEffects, type EggTourVisualState } from './components/EggGraphic'; export { SpecialMarkRenderer, SpecialMarkFallback } from './components/SpecialMarkRenderer'; // Hooks diff --git a/src/blobbi/egg/styles/egg-animations.css b/src/blobbi/egg/styles/egg-animations.css index 448275d7..96109dbb 100644 --- a/src/blobbi/egg/styles/egg-animations.css +++ b/src/blobbi/egg/styles/egg-animations.css @@ -320,6 +320,49 @@ transform: translateZ(0); } +/* ========================================== + Tour Visual State Animations + ========================================== */ + +/* Shell opening: scale up slightly then fade out with blur */ +@keyframes egg-tour-open { + 0% { + transform: scale(1); + opacity: 1; + filter: brightness(1.1); + } + 40% { + transform: scale(1.05); + opacity: 0.9; + filter: brightness(1.4); + } + 100% { + transform: scale(1.15); + opacity: 0; + filter: brightness(2) blur(4px); + } +} + +.animate-egg-tour-open { + animation: egg-tour-open 1.2s ease-in-out forwards; +} + +/* Pulsing glow for the "waiting for click" tour state */ +@keyframes egg-tour-glow { + 0%, 100% { + opacity: 0.5; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.08); + } +} + +.animate-egg-tour-glow { + animation: egg-tour-glow 2s ease-in-out infinite; +} + /* ========================================== Responsive adjustments ========================================== */ @@ -351,7 +394,9 @@ .animate-egg-sweat-drop, .animate-egg-dust-particle, .animate-egg-spiral, - .animate-egg-sparkle { + .animate-egg-sparkle, + .animate-egg-tour-glow, + .animate-egg-tour-open { animation: none !important; } } diff --git a/src/blobbi/onboarding/hooks/useBlobbiOnboarding.ts b/src/blobbi/onboarding/hooks/useBlobbiOnboarding.ts index 505e8f3a..030ee3da 100644 --- a/src/blobbi/onboarding/hooks/useBlobbiOnboarding.ts +++ b/src/blobbi/onboarding/hooks/useBlobbiOnboarding.ts @@ -456,15 +456,18 @@ export function useBlobbiOnboarding({ updateCompanionEvent(eggEvent); - // 2. Update profile: deduct coins, add to has, set current_companion + // 2. Update profile: deduct coins, add to has list + // NOTE: We do NOT set current_companion here because the adopted Blobbi + // is still an egg. The companion mechanic only becomes available after hatching. + // Eggs should never be auto-assigned as the floating companion. + // NOTE: blobbi_onboarding_done is NOT set here — adoption alone does not + // complete onboarding. It is set when the first-hatch tour finishes. const newCoins = coins - BLOBBI_ADOPTION_COST; const newHas = [...profile.has, preview.d]; const profileUpdates: Record = { coins: newCoins.toString(), has: newHas, - current_companion: preview.d, - onboarding_done: 'true', }; const updatedProfileTags = updateBlobbonautTags(profile.allTags, profileUpdates); diff --git a/src/blobbi/tour/components/FirstHatchTourCard.tsx b/src/blobbi/tour/components/FirstHatchTourCard.tsx new file mode 100644 index 00000000..125f1ebe --- /dev/null +++ b/src/blobbi/tour/components/FirstHatchTourCard.tsx @@ -0,0 +1,133 @@ +/** + * FirstHatchTourCard - Inline card shown below the egg during the first-hatch tour. + * + * Rendered directly in the BlobbiPage layout so the experience feels + * focused and guided. Adapts its messaging based on the current tour step. + * + * When the post mission is completed, the card stays visible with a + * celebratory completed state for ~2s (the parent auto-advances after + * that delay). This ensures the user sees the checkmark before the + * flow progresses to the egg-tap phase. + */ + +import { Send, Check, MousePointerClick } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; + +import type { FirstHatchTourStepId } from '../lib/tour-types'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface FirstHatchTourCardProps { + /** The Blobbi's display name */ + blobbiName: string; + /** The exact phrase the user needs to include in their post */ + requiredPhrase: string; + /** Whether the post mission has been completed */ + postCompleted: boolean; + /** Open the post composer */ + onCreatePost: () => void; + /** Current tour step id for adaptive messaging */ + currentStep: FirstHatchTourStepId | null; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function FirstHatchTourCard({ + blobbiName, + requiredPhrase, + postCompleted, + onCreatePost, + currentStep, +}: FirstHatchTourCardProps) { + const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1); + + // Determine which phase of the card to show + const isPostStep = currentStep === 'show_hatch_card'; + const isClickStep = currentStep === 'egg_glowing_waiting_click' + || currentStep === 'egg_crack_stage_1' + || currentStep === 'egg_crack_stage_2' + || currentStep === 'egg_crack_stage_3'; + + return ( +
+ {/* Title + description */} +
+

+ {isClickStep + ? `Tap ${capitalizedName} to hatch!` + : postCompleted && isPostStep + ? `${capitalizedName} heard you!` + : `${capitalizedName} is ready to hatch!`} +

+

+ {isClickStep + ? `Tap the egg to help ${capitalizedName} break free.` + : postCompleted && isPostStep + ? 'Your post was shared. Get ready to hatch...' + : `Share a post to the Nostr network and help ${capitalizedName} break free.`} +

+
+ + {/* Mission card - only during post step */} + {isPostStep && ( +
+ {postCompleted ? ( + /* ── Completed state — celebratory, stays visible ── */ +
+
+ +
+

+ Post shared! +

+

+ Continuing in a moment... +

+
+ ) : ( + /* ── Pending state — post mission ── */ + <> +
+
+
+

Share a hatch post

+

+ Your post must include: +

+

+ {requiredPhrase} +

+
+
+ + + + )} +
+ )} + + {/* Tap hint during click steps */} + {isClickStep && ( +
+ + Tap the egg +
+ )} + + {/* Extra hint for post step */} + {isPostStep && !postCompleted && ( +

+ You can add extra text before or after the required phrase. +

+ )} +
+ ); +} diff --git a/src/blobbi/tour/components/FirstHatchTourModal.tsx b/src/blobbi/tour/components/FirstHatchTourModal.tsx new file mode 100644 index 00000000..02ea8f9d --- /dev/null +++ b/src/blobbi/tour/components/FirstHatchTourModal.tsx @@ -0,0 +1,119 @@ +/** + * FirstHatchTourModal - Modal shown during the `show_hatch_modal` tour step. + * + * Tells the user their egg is about to hatch and guides them to create a post. + * Contains a single mission: create the hatch post. + */ + +import { Egg, Send, Check } from 'lucide-react'; + +import { + Dialog, + DialogContent, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface FirstHatchTourModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The Blobbi's display name */ + blobbiName: string; + /** The exact phrase the user needs to include in their post */ + requiredPhrase: string; + /** Whether the post mission has been completed */ + postCompleted: boolean; + /** Open the post composer */ + onCreatePost: () => void; + /** Advance the tour (called after post is confirmed complete) */ + onContinue: () => void; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function FirstHatchTourModal({ + open, + onOpenChange, + blobbiName, + requiredPhrase, + postCompleted, + onCreatePost, + onContinue, +}: FirstHatchTourModalProps) { + const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1); + + return ( + + + {/* Header with egg accent */} +
+
+ +
+ + + {capitalizedName} is ready to hatch! + + +

+ Share a post to the Nostr network and help {capitalizedName} break free. +

+
+ + {/* Mission card */} +
+
+
+ {/* Status indicator */} +
+ {postCompleted && } +
+ +
+

+ {postCompleted ? 'Post shared!' : 'Share a hatch post'} +

+

+ Post must include the phrase: +

+

+ {requiredPhrase} +

+
+
+ + {!postCompleted && ( + + )} +
+
+ + {/* Footer */} +
+ {postCompleted ? ( + + ) : ( +

+ You can add extra text before or after the required phrase. +

+ )} +
+
+
+ ); +} diff --git a/src/blobbi/tour/hooks/useFirstHatchTour.ts b/src/blobbi/tour/hooks/useFirstHatchTour.ts new file mode 100644 index 00000000..5275eb70 --- /dev/null +++ b/src/blobbi/tour/hooks/useFirstHatchTour.ts @@ -0,0 +1,226 @@ +/** + * useFirstHatchTour - State machine for the first-egg hatch tutorial. + * + * Orchestration only -- no rendering, no animations. + * The hook manages: + * - Ordered step progression + * - Persisted state via localStorage (survives refresh / close) + * - Derived booleans for UI consumption + * - Safe advance / goTo / complete / reset actions + * + * Activation is handled separately by useFirstHatchTourActivation, + * which calls `start()` when all preconditions are met. + * + * ──────────────────────────────────────────────────────────────── + * Future integration points + * ──────────────────────────────────────────────────────────────── + * 1. BlobbiPage (or a wrapper) calls useFirstHatchTourActivation + * to decide whether to start the tour. + * 2. UI components read `state.currentStepId` and render overlays, + * spotlights, modals, or animation cues accordingly. + * 3. Animation components call `actions.advance()` when their + * sequence finishes (for autoAdvance steps). + * 4. Interactive steps (e.g. "click the egg") call `actions.advance()` + * on the user interaction. + * 5. EggGraphic receives a visual-state prop derived from + * `state.currentStepId` -- it does NOT own the tour logic. + */ + +import { useMemo, useCallback, useRef } from 'react'; + +import { useLocalStorage } from '@/hooks/useLocalStorage'; + +import { + FIRST_HATCH_TOUR_STEPS, + FIRST_HATCH_TOUR_DEFAULT_STATE, + type FirstHatchTourStepId, + type FirstHatchTourPersistedState, + type TourState, + type TourActions, +} from '../lib/tour-types'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** + * localStorage key for the first hatch tour state. + * Not user-scoped because onboarding state is device-local and the tour + * is inherently tied to "first ever egg on this device". If multi-user + * support on the same device becomes a concern, scope by pubkey. + */ +const STORAGE_KEY = 'blobbi:tour:first-hatch'; + +/** Pre-computed lookup: stepId -> index */ +const STEP_INDEX_MAP = new Map( + FIRST_HATCH_TOUR_STEPS.map((step, i) => [step.id, i]), +); + +/** Index of the last step that is NOT the terminal 'complete' pseudo-step */ +const LAST_REAL_STEP_INDEX = FIRST_HATCH_TOUR_STEPS.length - 2; + +// ─── Result Type ────────────────────────────────────────────────────────────── + +export interface UseFirstHatchTourResult { + /** Reactive tour state for UI consumption */ + state: TourState; + /** Actions to drive the tour forward */ + actions: TourActions; + /** + * Convenience: check if the current step matches a given id. + * Useful for conditional rendering: `isStep('egg_crack_stage_1')`. + */ + isStep: (stepId: FirstHatchTourStepId) => boolean; + /** + * Convenience: check if the current step is one of the given ids. + * Useful for grouping: `isAnyStep('egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3')`. + */ + isAnyStep: (...stepIds: FirstHatchTourStepId[]) => boolean; + /** + * The current step definition (with autoAdvance metadata), or null. + */ + currentStepDef: (typeof FIRST_HATCH_TOUR_STEPS)[number] | null; +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +export function useFirstHatchTour(): UseFirstHatchTourResult { + // ── Persisted state ── + const [persisted, setPersisted] = useLocalStorage( + STORAGE_KEY, + FIRST_HATCH_TOUR_DEFAULT_STATE, + ); + + // Stable ref to current persisted state so callbacks never go stale. + const persistedRef = useRef(persisted); + persistedRef.current = persisted; + + // ── Helpers ── + + const updatePersisted = useCallback( + (patch: Partial) => { + setPersisted((prev) => ({ + ...prev, + ...patch, + updatedAt: Date.now(), + })); + }, + [setPersisted], + ); + + // ── Actions ── + + const start = useCallback(() => { + const p = persistedRef.current; + // No-op if already active or completed + if (p.completed || p.currentStepId !== null) return; + + const firstStep = FIRST_HATCH_TOUR_STEPS[0]; + if (!firstStep) return; + + updatePersisted({ currentStepId: firstStep.id }); + }, [updatePersisted]); + + const advance = useCallback(() => { + const p = persistedRef.current; + if (p.completed || p.currentStepId === null) return; + + const currentIndex = STEP_INDEX_MAP.get(p.currentStepId); + if (currentIndex === undefined) return; + + const nextIndex = currentIndex + 1; + if (nextIndex >= FIRST_HATCH_TOUR_STEPS.length) { + // Past the end -- complete + updatePersisted({ currentStepId: null, completed: true }); + return; + } + + const nextStep = FIRST_HATCH_TOUR_STEPS[nextIndex]; + if (nextStep.id === 'complete') { + // Reaching the 'complete' terminal step means the tour is done + updatePersisted({ currentStepId: null, completed: true }); + } else { + updatePersisted({ currentStepId: nextStep.id }); + } + }, [updatePersisted]); + + const goTo = useCallback( + (stepId: FirstHatchTourStepId) => { + if (!STEP_INDEX_MAP.has(stepId)) { + throw new Error(`[FirstHatchTour] Unknown step id: "${stepId}"`); + } + + if (stepId === 'complete') { + updatePersisted({ currentStepId: null, completed: true }); + } else { + updatePersisted({ currentStepId: stepId, completed: false }); + } + }, + [updatePersisted], + ); + + const complete = useCallback(() => { + updatePersisted({ currentStepId: null, completed: true }); + }, [updatePersisted]); + + const reset = useCallback(() => { + setPersisted(FIRST_HATCH_TOUR_DEFAULT_STATE); + }, [setPersisted]); + + // ── Derived state ── + + const currentStepIndex = persisted.currentStepId !== null + ? (STEP_INDEX_MAP.get(persisted.currentStepId) ?? -1) + : -1; + + const state = useMemo((): TourState => { + const isActive = persisted.currentStepId !== null && !persisted.completed; + const totalSteps = FIRST_HATCH_TOUR_STEPS.length; + + return { + isActive, + currentStepId: persisted.currentStepId, + currentStepIndex, + totalSteps, + isLastStep: currentStepIndex === LAST_REAL_STEP_INDEX, + isCompleted: persisted.completed, + progress: persisted.completed + ? 1 + : currentStepIndex >= 0 + ? currentStepIndex / LAST_REAL_STEP_INDEX + : 0, + }; + }, [persisted.currentStepId, persisted.completed, currentStepIndex]); + + const actions = useMemo((): TourActions => ({ + start, + advance, + goTo, + complete, + reset, + }), [start, advance, goTo, complete, reset]); + + // ── Convenience helpers ── + + const isStep = useCallback( + (stepId: FirstHatchTourStepId) => persisted.currentStepId === stepId, + [persisted.currentStepId], + ); + + const isAnyStep = useCallback( + (...stepIds: FirstHatchTourStepId[]) => { + return persisted.currentStepId !== null && stepIds.includes(persisted.currentStepId); + }, + [persisted.currentStepId], + ); + + const currentStepDef = currentStepIndex >= 0 + ? FIRST_HATCH_TOUR_STEPS[currentStepIndex] + : null; + + return { + state, + actions, + isStep, + isAnyStep, + currentStepDef, + }; +} diff --git a/src/blobbi/tour/hooks/useFirstHatchTourActivation.ts b/src/blobbi/tour/hooks/useFirstHatchTourActivation.ts new file mode 100644 index 00000000..fbb706a2 --- /dev/null +++ b/src/blobbi/tour/hooks/useFirstHatchTourActivation.ts @@ -0,0 +1,142 @@ +/** + * useFirstHatchTourActivation - Activation guard for the first-egg hatch tour. + * + * This hook checks all preconditions and calls `tour.actions.start()` when + * the tour should activate. It is intentionally separated from the tour + * state machine so that: + * - The state machine stays generic and reusable. + * - Activation rules are centralized in one place. + * - The rules are easy to read and modify. + * + * ──────────────────────────────────────────────────────────────── + * Activation rules (ALL must be true): + * ──────────────────────────────────────────────────────────────── + * 1. The companions list is loaded (not loading / error). + * 2. The user has exactly 1 Blobbi. + * 3. That Blobbi is in the egg stage. + * 4. No Blobbi is in baby or adult stage. + * 5. The tour has not been completed yet (checked via profile tag + * AND localStorage fallback). + * + * Completion is authoritative from the Blobbonaut profile event + * (`blobbi_onboarding_done` tag). localStorage (`blobbi:tour:first-hatch`) + * is a secondary signal for in-progress UI state and as a fallback + * when the profile hasn't been updated yet. + * ──────────────────────────────────────────────────────────────── + */ + +import { useEffect, useMemo } from 'react'; + +import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi'; + +import type { UseFirstHatchTourResult } from './useFirstHatchTour'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface FirstHatchTourActivationInput { + /** The full list of the user's Blobbi companions */ + companions: BlobbiCompanion[]; + /** Whether the companions list is still loading */ + isLoading: boolean; + /** The tour hook result (localStorage-based state machine) */ + tour: UseFirstHatchTourResult; + /** + * Whether onboarding is already marked complete in the Blobbonaut profile + * event (`blobbi_onboarding_done` tag). This is the authoritative source. + * When true, the tour will not activate regardless of localStorage state. + */ + profileOnboardingDone?: boolean; +} + +export interface FirstHatchTourActivationResult { + /** + * Whether all preconditions for activating the tour are met right now. + * This is a derived boolean -- it does NOT mean the tour IS active, + * just that it SHOULD be activated. The tour may already be active + * from a previous render or a persisted state. + */ + shouldActivate: boolean; + /** + * Whether the tour is eligible (preconditions met and not yet completed). + * Useful for hiding UI that should only appear during the tour window. + */ + isEligible: boolean; +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +/** + * Evaluates activation preconditions and auto-starts the tour when met. + * + * Usage: + * ```ts + * const tour = useFirstHatchTour(); + * const activation = useFirstHatchTourActivation({ + * companions, + * isLoading: companionsLoading, + * tour, + * profileOnboardingDone: profile?.onboardingDone, + * }); + * ``` + */ +export function useFirstHatchTourActivation({ + companions, + isLoading, + tour, + profileOnboardingDone = false, +}: FirstHatchTourActivationInput): FirstHatchTourActivationResult { + // ── Precondition evaluation ── + + const { shouldActivate, isEligible } = useMemo(() => { + // Can't evaluate until data is loaded + if (isLoading) { + return { shouldActivate: false, isEligible: false }; + } + + // Already completed — profile tag is the authoritative source, + // localStorage is a secondary fallback + if (profileOnboardingDone || tour.state.isCompleted) { + return { shouldActivate: false, isEligible: false }; + } + + // Must have exactly 1 companion + if (companions.length !== 1) { + return { shouldActivate: false, isEligible: false }; + } + + const onlyBlobbi = companions[0]; + + // That companion must be an egg + if (onlyBlobbi.stage !== 'egg') { + return { shouldActivate: false, isEligible: false }; + } + + // No baby or adult companions (redundant given length === 1 + stage === 'egg', + // but kept explicit for clarity and future-proofing if rules change) + const hasBabyOrAdult = companions.some( + (c) => c.stage === 'baby' || c.stage === 'adult', + ); + if (hasBabyOrAdult) { + return { shouldActivate: false, isEligible: false }; + } + + // All preconditions met + const eligible = true; + // Only activate if the tour is not already running + const activate = !tour.state.isActive; + + return { shouldActivate: activate, isEligible: eligible }; + }, [isLoading, companions, tour.state.isCompleted, tour.state.isActive, profileOnboardingDone]); + + // ── Auto-start effect ── + // When all preconditions are met and the tour hasn't started yet, + // start it. This fires once and then `shouldActivate` flips to false + // because `tour.state.isActive` becomes true. + useEffect(() => { + if (shouldActivate) { + tour.actions.start(); + } + }, [shouldActivate, tour.actions]); + + return { shouldActivate, isEligible }; +} diff --git a/src/blobbi/tour/index.ts b/src/blobbi/tour/index.ts new file mode 100644 index 00000000..564b3240 --- /dev/null +++ b/src/blobbi/tour/index.ts @@ -0,0 +1,46 @@ +/** + * Blobbi Tour Module + * + * Provides the orchestration layer for guided tours / tutorials. + * Currently implements the first-egg hatch tour. + * + * Architecture: + * - tour-types.ts: Step definitions, persisted state shape, generic types + * - useFirstHatchTour: State machine (step progression, persistence, actions) + * - useFirstHatchTourActivation: Precondition guard (auto-starts when eligible) + * + * UI components import from this barrel and read tour state to decide + * what to render. They call tour actions (advance, goTo, complete) in + * response to user interactions or animation completions. + */ + +// ── Types (generic tour infrastructure) ── +export type { + TourStepDef, + TourPersistedState, + TourState, + TourActions, +} from './lib/tour-types'; + +// ── First Hatch Tour - Types & Constants ── +export { + FIRST_HATCH_TOUR_STEPS, + FIRST_HATCH_TOUR_DEFAULT_STATE, +} from './lib/tour-types'; +export type { + FirstHatchTourStepId, + FirstHatchTourPersistedState, +} from './lib/tour-types'; + +// ── First Hatch Tour - Hooks ── +export { useFirstHatchTour } from './hooks/useFirstHatchTour'; +export type { UseFirstHatchTourResult } from './hooks/useFirstHatchTour'; + +export { useFirstHatchTourActivation } from './hooks/useFirstHatchTourActivation'; +export type { + FirstHatchTourActivationInput, + FirstHatchTourActivationResult, +} from './hooks/useFirstHatchTourActivation'; + +// ── First Hatch Tour - Components ── +export { FirstHatchTourCard } from './components/FirstHatchTourCard'; diff --git a/src/blobbi/tour/lib/tour-types.ts b/src/blobbi/tour/lib/tour-types.ts new file mode 100644 index 00000000..9ea7e981 --- /dev/null +++ b/src/blobbi/tour/lib/tour-types.ts @@ -0,0 +1,140 @@ +/** + * Tour System - Core Types + * + * Generic, reusable types for step-based guided tours. + * The tour system is designed to be: + * - Easy to extend with new tours (define steps + config) + * - Easy to reorder steps (change the STEPS array) + * - Persistent across page refreshes (localStorage) + * - Decoupled from rendering (UI reads state, doesn't own it) + */ + +// ─── Generic Tour Infrastructure ────────────────────────────────────────────── + +/** + * A tour step definition. + * + * Each step has a unique id and optional metadata that future UI layers + * can use to decide what to render (spotlights, modals, animations, etc.). + */ +export interface TourStepDef { + /** Unique identifier for this step */ + id: StepId; + /** + * Whether this step auto-advances (e.g. animations) or waits for + * an explicit `advance()` / `goTo()` call from the UI. + * Default: false (manual). + */ + autoAdvance?: boolean; +} + +/** + * Persisted state for a tour. + * Stored in localStorage so tours survive refresh / close / return. + */ +export interface TourPersistedState { + /** Current step id, or null when the tour is not yet started */ + currentStepId: StepId | null; + /** Whether the tour has been completed */ + completed: boolean; + /** Unix ms timestamp of last state change (for debugging / analytics) */ + updatedAt: number; +} + +/** + * Full runtime state exposed by a tour hook. + */ +export interface TourState { + /** Whether the tour is currently active (started and not yet completed) */ + isActive: boolean; + /** Current step id, or null when idle / completed */ + currentStepId: StepId | null; + /** 0-based index of the current step in the steps array, or -1 */ + currentStepIndex: number; + /** Total number of steps */ + totalSteps: number; + /** Whether the current step is the last one before completion */ + isLastStep: boolean; + /** Whether the tour has been completed (persisted) */ + isCompleted: boolean; + /** Progress as a fraction 0..1 */ + progress: number; +} + +/** + * Actions exposed by a tour hook. + */ +export interface TourActions { + /** Start the tour from the first step (no-op if already active or completed) */ + start: () => void; + /** Advance to the next step. Completes the tour if on the last step. */ + advance: () => void; + /** Jump to a specific step by id. Throws if the step doesn't exist. */ + goTo: (stepId: StepId) => void; + /** Mark the tour as completed and reset to idle. */ + complete: () => void; + /** Reset the tour entirely (clears persisted state). For dev/testing. */ + reset: () => void; +} + +// ─── First Hatch Tour ───────────────────────────────────────────────────────── + +/** + * Step ids for the first-egg hatch tour. + * + * Flow: + * 1. idle — initial state (auto-advances immediately) + * 2. show_hatch_card — egg with initial crack + wiggle + inline card + * 3. egg_glowing_waiting_click — post done, egg glows, waiting for user click + * 4. egg_crack_stage_1 — click 1: crack expands + * 5. egg_crack_stage_2 — click 2: crack expands further + * 6. egg_crack_stage_3 — click 3: crack reaches edges + * 7. egg_opening — shell opens (auto-advance after animation) + * 8. egg_hatching — bright light + baby reveal (auto-advance) + * 9. complete — terminal, marks tour done + * + * The order here matches the intended flow. To reorder steps, + * change FIRST_HATCH_TOUR_STEPS (the array), not this type. + */ +export type FirstHatchTourStepId = + | 'idle' + | 'show_hatch_card' + | 'egg_glowing_waiting_click' + | 'egg_crack_stage_1' + | 'egg_crack_stage_2' + | 'egg_crack_stage_3' + | 'egg_opening' + | 'egg_hatching' + | 'complete'; + +/** + * Ordered step definitions for the first hatch tour. + * + * To add / remove / reorder steps, edit this array. + * The tour state machine walks through these in order. + */ +export const FIRST_HATCH_TOUR_STEPS: TourStepDef[] = [ + { id: 'idle' }, + { id: 'show_hatch_card' }, + { id: 'egg_glowing_waiting_click' }, + { id: 'egg_crack_stage_1' }, + { id: 'egg_crack_stage_2' }, + { id: 'egg_crack_stage_3' }, + { id: 'egg_opening', autoAdvance: true }, + { id: 'egg_hatching', autoAdvance: true }, + { id: 'complete' }, +]; + +/** + * Persisted state shape for the first hatch tour. + */ +export type FirstHatchTourPersistedState = TourPersistedState; + +/** + * Default persisted state for a brand-new first hatch tour. + */ +export const FIRST_HATCH_TOUR_DEFAULT_STATE: FirstHatchTourPersistedState = { + currentStepId: null, + completed: false, + updatedAt: 0, +}; diff --git a/src/blobbi/ui/BlobbiEggVisual.tsx b/src/blobbi/ui/BlobbiEggVisual.tsx index 0d8297ce..3dc77d6d 100644 --- a/src/blobbi/ui/BlobbiEggVisual.tsx +++ b/src/blobbi/ui/BlobbiEggVisual.tsx @@ -13,7 +13,7 @@ import { useMemo } from 'react'; -import { EggGraphic, type EggReactionState, type EggStatusEffects } from '@/blobbi/egg'; +import { EggGraphic, type EggReactionState, type EggStatusEffects, type EggTourVisualState } from '@/blobbi/egg'; import { toEggGraphicVisualBlobbi } from '@/blobbi/core/lib/blobbi-egg-adapter'; import { cn } from '@/lib/utils'; import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi'; @@ -23,7 +23,7 @@ import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi'; export type BlobbiEggSize = 'sm' | 'md' | 'lg'; // Re-export for convenience -export type { EggReactionState, EggStatusEffects } from '@/blobbi/egg'; +export type { EggReactionState, EggStatusEffects, EggTourVisualState } from '@/blobbi/egg'; export interface BlobbiEggVisualProps { /** The Blobbi companion data from parseBlobbiEvent */ @@ -36,6 +36,10 @@ export interface BlobbiEggVisualProps { reaction?: EggReactionState; /** Status effects for egg visual feedback (dirty, sick, happy) */ statusEffects?: EggStatusEffects; + /** Tour visual state - driven externally by the tour orchestration layer */ + tourVisualState?: EggTourVisualState; + /** Callback when the egg is clicked during an interactive tour step */ + onTourEggClick?: () => void; /** Additional CSS classes for the container */ className?: string; } @@ -70,6 +74,8 @@ export function BlobbiEggVisual({ animated = false, reaction = 'idle', statusEffects, + tourVisualState, + onTourEggClick, className, }: BlobbiEggVisualProps) { // Memoize adapter output to avoid unnecessary re-renders @@ -103,6 +109,8 @@ export function BlobbiEggVisual({ animated={animated && !isSleeping} reaction={effectiveReaction} statusEffects={isSleeping ? undefined : statusEffects} + tourVisualState={tourVisualState} + onTourEggClick={onTourEggClick} />
); diff --git a/src/blobbi/ui/BlobbiStageVisual.tsx b/src/blobbi/ui/BlobbiStageVisual.tsx index 7b8ee177..5134ce20 100644 --- a/src/blobbi/ui/BlobbiStageVisual.tsx +++ b/src/blobbi/ui/BlobbiStageVisual.tsx @@ -12,7 +12,7 @@ import { useMemo } from 'react'; -import { BlobbiEggVisual, type BlobbiEggSize, type EggStatusEffects } from './BlobbiEggVisual'; +import { BlobbiEggVisual, type BlobbiEggSize, type EggStatusEffects, type EggTourVisualState } from './BlobbiEggVisual'; import { BlobbiBabyVisual } from './BlobbiBabyVisual'; import { BlobbiAdultVisual } from './BlobbiAdultVisual'; import { FloatingMusicNotes } from './FloatingMusicNotes'; @@ -50,6 +50,10 @@ export interface BlobbiStageVisualProps { * Status-reaction body effects are already in the recipe. */ bodyEffects?: BodyEffectsSpec; + /** Tour visual state for egg stage - driven by the tour orchestration layer */ + tourVisualState?: EggTourVisualState; + /** Callback when the egg is clicked during an interactive tour step */ + onTourEggClick?: () => void; className?: string; } @@ -74,6 +78,8 @@ export function BlobbiStageVisual({ recipeLabel, emotion = 'neutral', bodyEffects, + tourVisualState, + onTourEggClick, className, }: BlobbiStageVisualProps) { const { stage } = companion; @@ -109,6 +115,8 @@ export function BlobbiStageVisual({ animated={animated} reaction={effectiveReaction} statusEffects={eggStatusEffects} + tourVisualState={tourVisualState} + onTourEggClick={onTourEggClick} className="size-full" /> diff --git a/src/blobbi/ui/components/ActionBarEditor.tsx b/src/blobbi/ui/components/ActionBarEditor.tsx new file mode 100644 index 00000000..0ff14878 --- /dev/null +++ b/src/blobbi/ui/components/ActionBarEditor.tsx @@ -0,0 +1,208 @@ +/** + * ActionBarEditor - Lightweight modal for customizing the bottom action bar. + * + * Rules: + * - Main Action + More are fixed (always shown, not editable) + * - Up to 3 custom visible slots + * - User can toggle visibility, reorder, and highlight one item + */ + +import { useCallback } from 'react'; +import { + ChevronUp, + ChevronDown, + Eye, + EyeOff, + Star, + Egg, + Target, + Package, + Camera, + Footprints, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { cn } from '@/lib/utils'; + +import { + type ActionBarPreferences, + type BarItemId, + BAR_ITEM_LABELS, + MAX_VISIBLE_SLOTS, + toggleSlotVisibility, + toggleSlotHighlight, + moveSlotUp, + moveSlotDown, + visibleCount, + DEFAULT_PREFERENCES, +} from '../lib/action-bar-preferences'; + +// ─── Icon Mapping ───────────────────────────────────────────────────────────── + +const BAR_ITEM_ICONS: Record = { + blobbies: , + missions: , + items: , + take_photo: , + set_companion: , +}; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface ActionBarEditorProps { + open: boolean; + onOpenChange: (open: boolean) => void; + preferences: ActionBarPreferences; + onUpdate: (prefs: ActionBarPreferences) => void; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ActionBarEditor({ + open, + onOpenChange, + preferences, + onUpdate, +}: ActionBarEditorProps) { + const currentVisible = visibleCount(preferences); + const atMax = currentVisible >= MAX_VISIBLE_SLOTS; + + const handleToggle = useCallback( + (id: BarItemId) => onUpdate(toggleSlotVisibility(preferences, id)), + [preferences, onUpdate], + ); + + const handleHighlight = useCallback( + (id: BarItemId) => onUpdate(toggleSlotHighlight(preferences, id)), + [preferences, onUpdate], + ); + + const handleUp = useCallback( + (id: BarItemId) => onUpdate(moveSlotUp(preferences, id)), + [preferences, onUpdate], + ); + + const handleDown = useCallback( + (id: BarItemId) => onUpdate(moveSlotDown(preferences, id)), + [preferences, onUpdate], + ); + + const handleReset = useCallback( + () => onUpdate(DEFAULT_PREFERENCES), + [onUpdate], + ); + + return ( + + + + Edit Action Bar + + Choose up to {MAX_VISIBLE_SLOTS} items. Main Action and More are always shown. + + + +
+ {preferences.slots.map((slot, idx) => { + const isFirst = idx === 0; + const isLast = idx === preferences.slots.length - 1; + const canTurnOn = slot.visible || !atMax; + + return ( +
+ {/* Icon + Label */} +
+ {BAR_ITEM_ICONS[slot.id]} + + {BAR_ITEM_LABELS[slot.id]} + +
+ + {/* Highlight toggle */} + {slot.visible && ( + + )} + + {/* Reorder controls */} +
+ + +
+ + {/* Visibility toggle */} + +
+ ); + })} +
+ + {/* Slot counter + reset */} +
+ + {currentVisible}/{MAX_VISIBLE_SLOTS} slots used + + +
+
+
+ ); +} diff --git a/src/blobbi/ui/components/MissionSurfaceCard.tsx b/src/blobbi/ui/components/MissionSurfaceCard.tsx new file mode 100644 index 00000000..3994b7ba --- /dev/null +++ b/src/blobbi/ui/components/MissionSurfaceCard.tsx @@ -0,0 +1,301 @@ +/** + * MissionSurfaceCard - Compact inline card that surfaces ONE relevant + * mission/task at a time below the Blobbi visual. + * + * Priority: + * 1. Hatch / Evolve tasks (lifecycle progression) + * 2. Daily missions (engagement / coin loop) + * + * Carousel: + * - Auto-rotates every ~5s when > 1 card available + * - Manual tap cycles to the next card + * - Auto-advances when the current card's mission completes + * - Single card = no rotation + */ + +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { + Target, + ChevronRight, + Egg, + Sparkles, + Coins, + CircleDot, + X, +} from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { cn } from '@/lib/utils'; + +import type { HatchTask } from '@/blobbi/actions/hooks/useHatchTasks'; +import type { DailyMission } from '@/blobbi/actions/lib/daily-missions'; + +// ─── Card Item Types ────────────────────────────────────────────────────────── + +interface TaskCardItem { + kind: 'task'; + badge: 'Hatch' | 'Evolve'; + title: string; + description: string; + progress: number; // 0-100 + progressLabel: string; +} + +interface DailyCardItem { + kind: 'daily'; + badge: 'Daily'; + title: string; + description: string; + progress: number; + progressLabel: string; + reward: number; + claimed: boolean; +} + +type CardItem = TaskCardItem | DailyCardItem; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface MissionSurfaceCardProps { + /** Hatch or evolve tasks (from useActiveTaskProcess) */ + tasks: HatchTask[]; + /** Whether a task process (incubating/evolving) is active */ + isInTaskProcess: boolean; + /** Process type for badge label */ + processType: 'hatch' | 'evolve' | null; + /** Daily missions */ + dailyMissions: DailyMission[]; + /** Called when user taps "View all" */ + onViewAll: () => void; + /** Called when user dismisses the card */ + onHide?: () => void; + /** Additional className */ + className?: string; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function buildTaskCards( + tasks: HatchTask[], + processType: 'hatch' | 'evolve' | null, +): TaskCardItem[] { + if (!processType) return []; + const badge = processType === 'hatch' ? 'Hatch' : 'Evolve'; + + // Show only incomplete tasks, or the first completed one if all are done + const incomplete = tasks.filter((t) => !t.completed); + const toShow = incomplete.length > 0 ? incomplete : tasks.slice(0, 1); + + return toShow.map((t) => ({ + kind: 'task', + badge: badge as 'Hatch' | 'Evolve', + title: t.name, + description: t.description, + progress: t.required > 0 ? Math.min(100, Math.round((t.current / t.required) * 100)) : 0, + progressLabel: `${t.current}/${t.required}`, + })); +} + +function buildDailyCards(missions: DailyMission[]): DailyCardItem[] { + // Show unclaimed missions first, then claimed ones + const unclaimed = missions.filter((m) => !m.claimed); + const toShow = unclaimed.length > 0 ? unclaimed : []; + + return toShow.map((m) => ({ + kind: 'daily', + badge: 'Daily', + title: m.title, + description: m.description, + progress: m.requiredCount > 0 + ? Math.min(100, Math.round((m.currentCount / m.requiredCount) * 100)) + : 0, + progressLabel: `${m.currentCount}/${m.requiredCount}`, + reward: m.reward, + claimed: m.claimed, + })); +} + +// ─── Auto-rotate interval ───────────────────────────────────────────────────── +const ROTATE_INTERVAL_MS = 5000; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function MissionSurfaceCard({ + tasks, + isInTaskProcess, + processType, + dailyMissions, + onViewAll, + onHide, + className, +}: MissionSurfaceCardProps) { + const [activeIndex, setActiveIndex] = useState(0); + const [isAnimating, setIsAnimating] = useState(false); + + // Build card list: tasks first (priority), then daily + const cards = useMemo(() => { + const taskCards = isInTaskProcess ? buildTaskCards(tasks, processType) : []; + const dailyCards = buildDailyCards(dailyMissions); + return [...taskCards, ...dailyCards]; + }, [tasks, isInTaskProcess, processType, dailyMissions]); + + // Clamp index if cards shrink + useEffect(() => { + if (activeIndex >= cards.length && cards.length > 0) { + setActiveIndex(0); + } + }, [cards.length, activeIndex]); + + // Auto-rotate (only when > 1 card) + const timerRef = useRef | null>(null); + + useEffect(() => { + if (cards.length <= 1) { + if (timerRef.current) clearInterval(timerRef.current); + return; + } + + timerRef.current = setInterval(() => { + setIsAnimating(true); + setTimeout(() => { + setActiveIndex((prev) => (prev + 1) % cards.length); + setIsAnimating(false); + }, 150); + }, ROTATE_INTERVAL_MS); + + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [cards.length]); + + // Manual cycle + const handleCycle = useCallback(() => { + if (cards.length <= 1) return; + // Reset auto-rotate timer + if (timerRef.current) clearInterval(timerRef.current); + setIsAnimating(true); + setTimeout(() => { + setActiveIndex((prev) => (prev + 1) % cards.length); + setIsAnimating(false); + // Restart timer + timerRef.current = setInterval(() => { + setIsAnimating(true); + setTimeout(() => { + setActiveIndex((prev) => (prev + 1) % cards.length); + setIsAnimating(false); + }, 150); + }, ROTATE_INTERVAL_MS); + }, 150); + }, [cards.length]); + + // Nothing to show + if (cards.length === 0) return null; + + const card = cards[Math.min(activeIndex, cards.length - 1)]; + + const badgeColor = + card.badge === 'Hatch' + ? 'bg-amber-500/15 text-amber-600 dark:text-amber-400' + : card.badge === 'Evolve' + ? 'bg-purple-500/15 text-purple-600 dark:text-purple-400' + : 'bg-primary/10 text-primary'; + + const badgeIcon = + card.badge === 'Hatch' ? ( + + ) : card.badge === 'Evolve' ? ( + + ) : ( + + ); + + return ( +
+ + )} +
+ + {/* Description */} +

+ {card.description} +

+ + {/* Bottom row: progress bar + label + reward/view all */} +
+ + + {card.progressLabel} + + {card.kind === 'daily' && !card.claimed && ( + + + {card.reward} + + )} +
+ + + {/* View all link */} + +
+ ); +} diff --git a/src/blobbi/ui/lib/action-bar-preferences.ts b/src/blobbi/ui/lib/action-bar-preferences.ts new file mode 100644 index 00000000..e6098968 --- /dev/null +++ b/src/blobbi/ui/lib/action-bar-preferences.ts @@ -0,0 +1,251 @@ +/** + * Action Bar Preferences + * + * Lightweight localStorage-backed model controlling which items are + * visible in the BlobbiBottomBar and in which order. + * + * Fixed items (cannot be hidden or reordered by the user): + * - Main Action (center button) -- always present + * - More (right-most button) -- always present + * + * Customizable items (up to 3 visible slots): + * Candidates: Blobbies, Missions, Items, Take Photo, Set as Companion + * + * Persistence: localStorage only for now. Shape is designed so it can + * later migrate to a Nostr event tag. + */ + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** Identifiers for customizable bottom-bar items */ +export type BarItemId = + | 'blobbies' + | 'missions' + | 'items' + | 'take_photo' + | 'set_companion'; + +/** A single customizable bar slot */ +export interface BarItemSlot { + id: BarItemId; + visible: boolean; + /** If true, this item receives a subtle highlight ring in the bar */ + highlighted?: boolean; +} + +/** Full persisted preference shape */ +export interface ActionBarPreferences { + /** Ordered list of customizable items. Visible items render in array order. */ + slots: BarItemSlot[]; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** Max visible customizable items (Main Action + More are fixed) */ +export const MAX_VISIBLE_SLOTS = 3; + +/** localStorage key for bar slot preferences */ +export const STORAGE_KEY = 'blobbi:action-bar-prefs'; + +/** localStorage key for inline mission surface card visibility */ +export const MISSION_CARD_STORAGE_KEY = 'blobbi:mission-card-visible'; + +/** Human-readable labels */ +export const BAR_ITEM_LABELS: Record = { + blobbies: 'Blobbies', + missions: 'Missions', + items: 'Items', + take_photo: 'Take Photo', + set_companion: 'Companion', +}; + +/** Default preferences: only Blobbies visible, others hidden */ +export const DEFAULT_PREFERENCES: ActionBarPreferences = { + slots: [ + { id: 'blobbies', visible: true }, + { id: 'missions', visible: false }, + { id: 'items', visible: false }, + { id: 'take_photo', visible: false }, + { id: 'set_companion', visible: false }, + ], +}; + +// ─── Utilities ──────────────────────────────────────────────────────────────── + +/** Return only visible slots, in order */ +export function getVisibleSlots(prefs: ActionBarPreferences): BarItemSlot[] { + return prefs.slots.filter((s) => s.visible); +} + +/** Count of currently visible custom items */ +export function visibleCount(prefs: ActionBarPreferences): number { + return prefs.slots.filter((s) => s.visible).length; +} + +/** Can we show one more item? */ +export function canShowMore(prefs: ActionBarPreferences): boolean { + return visibleCount(prefs) < MAX_VISIBLE_SLOTS; +} + +/** Toggle visibility of a slot. Enforces MAX_VISIBLE_SLOTS. */ +export function toggleSlotVisibility( + prefs: ActionBarPreferences, + id: BarItemId, +): ActionBarPreferences { + const slot = prefs.slots.find((s) => s.id === id); + if (!slot) return prefs; + + // If turning ON and already at max, reject + if (!slot.visible && !canShowMore(prefs)) return prefs; + + return { + slots: prefs.slots.map((s) => + s.id === id ? { ...s, visible: !s.visible } : s, + ), + }; +} + +/** Toggle highlight on a slot (only one can be highlighted at a time) */ +export function toggleSlotHighlight( + prefs: ActionBarPreferences, + id: BarItemId, +): ActionBarPreferences { + return { + slots: prefs.slots.map((s) => + s.id === id + ? { ...s, highlighted: !s.highlighted } + : { ...s, highlighted: false }, + ), + }; +} + +/** Move a slot up (earlier) in the list */ +export function moveSlotUp( + prefs: ActionBarPreferences, + id: BarItemId, +): ActionBarPreferences { + const idx = prefs.slots.findIndex((s) => s.id === id); + if (idx <= 0) return prefs; + const newSlots = [...prefs.slots]; + [newSlots[idx - 1], newSlots[idx]] = [newSlots[idx], newSlots[idx - 1]]; + return { slots: newSlots }; +} + +/** Move a slot down (later) in the list */ +export function moveSlotDown( + prefs: ActionBarPreferences, + id: BarItemId, +): ActionBarPreferences { + const idx = prefs.slots.findIndex((s) => s.id === id); + if (idx < 0 || idx >= prefs.slots.length - 1) return prefs; + const newSlots = [...prefs.slots]; + [newSlots[idx], newSlots[idx + 1]] = [newSlots[idx + 1], newSlots[idx]]; + return { slots: newSlots }; +} + +/** + * Validate and repair preferences loaded from localStorage. + * Adds missing candidates, removes unknown ids, preserves order. + */ +export function validatePreferences(raw: unknown): ActionBarPreferences { + if (!raw || typeof raw !== 'object' || !('slots' in raw)) { + return DEFAULT_PREFERENCES; + } + + const obj = raw as { slots: unknown }; + if (!Array.isArray(obj.slots)) return DEFAULT_PREFERENCES; + + const knownIds = new Set(DEFAULT_PREFERENCES.slots.map((s) => s.id)); + const seenIds = new Set(); + + // Keep valid existing entries + const cleaned: BarItemSlot[] = []; + for (const item of obj.slots) { + if ( + item && + typeof item === 'object' && + 'id' in item && + typeof (item as BarItemSlot).id === 'string' && + knownIds.has((item as BarItemSlot).id) && + !seenIds.has((item as BarItemSlot).id) + ) { + const slot = item as BarItemSlot; + seenIds.add(slot.id); + cleaned.push({ + id: slot.id, + visible: typeof slot.visible === 'boolean' ? slot.visible : false, + highlighted: typeof slot.highlighted === 'boolean' ? slot.highlighted : false, + }); + } + } + + // Add any missing candidates (new features added after user saved prefs) + for (const def of DEFAULT_PREFERENCES.slots) { + if (!seenIds.has(def.id)) { + cleaned.push({ ...def }); + } + } + + return { slots: cleaned }; +} + +/** + * Load preferences from localStorage with validation. + */ +export function loadPreferences(): ActionBarPreferences { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULT_PREFERENCES; + return validatePreferences(JSON.parse(raw)); + } catch { + return DEFAULT_PREFERENCES; + } +} + +/** + * Save preferences to localStorage. + */ +export function savePreferences(prefs: ActionBarPreferences): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); + } catch { + // Silently fail (quota, SSR, etc.) + } +} + +// ─── Mission Surface Card Visibility ────────────────────────────────────────── + +/** + * Load the inline mission card visibility preference. + * Defaults to `true` (visible). + */ +export function loadMissionCardVisible(): boolean { + try { + const raw = localStorage.getItem(MISSION_CARD_STORAGE_KEY); + if (raw === null) return true; + return raw === 'true'; + } catch { + return true; + } +} + +/** + * Save the inline mission card visibility preference. + */ +export function saveMissionCardVisible(visible: boolean): void { + try { + localStorage.setItem(MISSION_CARD_STORAGE_KEY, String(visible)); + } catch { + // Silently fail + } +} + +// ─── Visible-in-bar Set Helper ──────────────────────────────────────────────── + +/** + * Return the set of BarItemIds currently visible in the bottom bar. + * Used by the More dropdown to skip items that are already in the bar. + */ +export function getVisibleBarIds(prefs: ActionBarPreferences): Set { + return new Set(prefs.slots.filter((s) => s.visible).map((s) => s.id)); +} diff --git a/src/hooks/useBlobbiMigration.ts b/src/hooks/useBlobbiMigration.ts deleted file mode 100644 index a8bcad13..00000000 --- a/src/hooks/useBlobbiMigration.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { useCallback } from 'react'; -import type { NostrEvent } from '@nostrify/nostrify'; - -import { useCurrentUser } from './useCurrentUser'; -import { useNostrPublish } from './useNostrPublish'; -import { toast } from './useToast'; - -import { - KIND_BLOBBI_STATE, - KIND_BLOBBONAUT_PROFILE, - buildMigrationTags, - generatePetId10, - getCanonicalBlobbiD, - migratePetInHas, - updateBlobbonautTags, - parseBlobbiEvent, - parseStorageTags, - type BlobbiCompanion, - type BlobbonautProfile, - type StorageItem, -} from '@/lib/blobbi'; - -/** - * Result of a successful migration. - */ -export interface MigrationResult { - /** The new canonical d-tag */ - canonicalD: string; - /** The published canonical Blobbi event */ - event: NostrEvent; - /** The parsed canonical BlobbiCompanion */ - companion: BlobbiCompanion; - /** The updated profile event */ - profileEvent: NostrEvent; - /** The updated profile tags (canonical has, current_companion, etc.) */ - profileTags: string[][]; - /** The profile storage (unchanged during migration, but fresh from migrated profile) */ - profileStorage: StorageItem[]; -} - -/** - * Options for the migration helper. - */ -export interface EnsureCanonicalOptions { - /** The companion to check/migrate */ - companion: BlobbiCompanion; - /** The user's profile */ - profile: BlobbonautProfile; - /** Callback to update the profile event in query cache */ - updateProfileEvent: (event: NostrEvent) => void; - /** Callback to update the companion event in query cache */ - updateCompanionEvent: (event: NostrEvent) => void; - /** Callback to update localStorage selection if it was pointing to legacy d */ - updateStoredSelectedD?: (newD: string) => void; - /** Callback to invalidate companion query */ - invalidateCompanion?: () => void; - /** Callback to invalidate profile query */ - invalidateProfile?: () => void; -} - -/** - * Result of ensureCanonicalBlobbiBeforeAction. - */ -export interface EnsureCanonicalResult { - /** Whether the companion was migrated */ - wasMigrated: boolean; - /** The canonical companion (either the original or the migrated one) */ - companion: BlobbiCompanion; - /** The canonical event tags to use for the action */ - allTags: string[][]; - /** The event content to use */ - content: string; - /** - * The latest profile tags to use for profile updates. - * IMPORTANT: Always use these instead of profile.allTags from hook closure - * to avoid restoring stale/legacy values after migration. - */ - profileAllTags: string[][]; - /** - * The latest profile storage to use. - * Use this as the base for storage modifications. - */ - profileStorage: StorageItem[]; -} - -/** - * Hook providing centralized migration logic for Blobbi companions. - * - * This hook should be used by all action handlers to ensure legacy Blobbis - * are automatically migrated before any interaction. - * - * Usage: - * ```ts - * const { ensureCanonicalBlobbiBeforeAction } = useBlobbiMigration(); - * - * const handleFeed = async () => { - * const result = await ensureCanonicalBlobbiBeforeAction({ - * companion, - * profile, - * updateProfileEvent, - * updateCompanionEvent, - * updateStoredSelectedD: setStoredSelectedD, - * }); - * - * if (!result) return; // Migration failed - * - * // Continue with the action using result.companion and result.allTags - * const newTags = updateBlobbiTags(result.allTags, { ... }); - * // ... publish event - * }; - * ``` - */ -export function useBlobbiMigration() { - const { user } = useCurrentUser(); - const { mutateAsync: publishEvent } = useNostrPublish(); - - /** - * Migrate a legacy Blobbi to canonical format. - * - * This function: - * 1. Generates a canonical d-tag - * 2. Ensures a seed exists (generates one if missing) - * 3. Preserves name, stage, stats, state, timestamps - * 4. Publishes a canonical 31124 event - * 5. Updates the Blobbonaut profile (kind 11125) - * 6. Updates local state (query cache, localStorage) - */ - const migrateLegacyBlobbi = useCallback(async ( - options: EnsureCanonicalOptions - ): Promise => { - const { - companion, - profile, - updateProfileEvent, - updateCompanionEvent, - updateStoredSelectedD, - invalidateCompanion, - invalidateProfile, - } = options; - - if (!user?.pubkey) { - console.error('[Blobbi Migration] No user pubkey'); - return null; - } - - console.log('[Blobbi Migration] Starting migration for:', companion.d); - - try { - // Generate new canonical d-tag - const newPetId = generatePetId10(); - const canonicalD = getCanonicalBlobbiD(user.pubkey, newPetId); - - // Build migration tags (preserves name, stage, stats, generates seed if missing) - const migrationTags = buildMigrationTags(companion.event, newPetId, user.pubkey); - - console.log('[Blobbi Migration] Publishing canonical event with d:', canonicalD); - - // Publish the canonical Blobbi state - const canonicalEvent = await publishEvent({ - kind: KIND_BLOBBI_STATE, - content: companion.event.content || `${companion.name} is a ${companion.stage} Blobbi.`, - tags: migrationTags, - }); - - // Parse the new event to get the canonical companion - const canonicalCompanion = parseBlobbiEvent(canonicalEvent); - if (!canonicalCompanion) { - throw new Error('Failed to parse migrated event'); - } - - // Update profile: replace legacy d with canonical d in has[], update current_companion - const updatedHas = migratePetInHas(profile.has, companion.d, canonicalD); - const shouldUpdateCurrentCompanion = profile.currentCompanion === companion.d; - - const profileUpdates: Record = { - has: updatedHas, - }; - - if (shouldUpdateCurrentCompanion) { - profileUpdates.current_companion = canonicalD; - } - - const profileTags = updateBlobbonautTags(profile.allTags, profileUpdates); - - console.log('[Blobbi Migration] Publishing updated profile'); - - const profileEvent = await publishEvent({ - kind: KIND_BLOBBONAUT_PROFILE, - content: '', - tags: profileTags, - }); - - // Update query caches - updateProfileEvent(profileEvent); - updateCompanionEvent(canonicalEvent); - - // Update localStorage selection if it was pointing to legacy d - if (updateStoredSelectedD) { - console.log('[Blobbi Migration] Updating localStorage selection:', canonicalD); - updateStoredSelectedD(canonicalD); - } - - // Invalidate queries to refetch fresh data - invalidateCompanion?.(); - invalidateProfile?.(); - - toast({ - title: 'Pet upgraded!', - description: `${companion.name} has been migrated to the new format.`, - }); - - console.log('[Blobbi Migration] Migration complete:', { - legacyD: companion.d, - canonicalD, - }); - - // Parse storage from the migrated profile tags - // Storage itself doesn't change during migration, but we need fresh tags - const migratedStorage = parseStorageTags(profileTags); - - return { - canonicalD, - event: canonicalEvent, - companion: canonicalCompanion, - profileEvent, - profileTags, - profileStorage: migratedStorage, - }; - } catch (error) { - console.error('[Blobbi Migration] Migration failed:', error); - toast({ - title: 'Migration failed', - description: error instanceof Error ? error.message : 'Unknown error', - variant: 'destructive', - }); - return null; - } - }, [user?.pubkey, publishEvent]); - - /** - * Ensure a Blobbi is in canonical format before performing an action. - * - * If the companion is legacy, it will be migrated first. - * Returns the canonical companion to use for the action. - * - * Flow: - * 1. Check if Blobbi is legacy - * 2. If legacy: migrate Blobbi - * 3. Return the resolved canonical Blobbi - * - * All interaction handlers should call this before publishing events. - */ - const ensureCanonicalBlobbiBeforeAction = useCallback(async ( - options: EnsureCanonicalOptions - ): Promise => { - const { companion, profile } = options; - - // Check if the companion needs migration - if (companion.isLegacy) { - console.log('[Blobbi Migration] Legacy companion detected, migrating before action'); - - const migrationResult = await migrateLegacyBlobbi(options); - - if (!migrationResult) { - // Migration failed, cannot proceed with action - return null; - } - - // Return the canonical companion AND migrated profile context - // CRITICAL: Consumers must use profileAllTags instead of profile.allTags - // to avoid restoring stale/legacy values - return { - wasMigrated: true, - companion: migrationResult.companion, - allTags: migrationResult.event.tags, - content: migrationResult.event.content, - profileAllTags: migrationResult.profileTags, - profileStorage: migrationResult.profileStorage, - }; - } - - // Companion is already canonical, return profile as-is - return { - wasMigrated: false, - companion, - allTags: companion.allTags, - content: companion.event.content, - profileAllTags: profile.allTags, - profileStorage: profile.storage, - }; - }, [migrateLegacyBlobbi]); - - return { - /** Migrate a legacy Blobbi to canonical format */ - migrateLegacyBlobbi, - /** Ensure a Blobbi is canonical before an action, migrating if necessary */ - ensureCanonicalBlobbiBeforeAction, - }; -} diff --git a/src/hooks/useBlobbisCollection.ts b/src/hooks/useBlobbisCollection.ts deleted file mode 100644 index 32aa4430..00000000 --- a/src/hooks/useBlobbisCollection.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { useNostr } from '@nostrify/react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import type { NostrEvent } from '@nostrify/nostrify'; - -import { useCurrentUser } from './useCurrentUser'; -import { - KIND_BLOBBI_STATE, - isValidBlobbiEvent, - parseBlobbiEvent, - type BlobbiCompanion, -} from '@/lib/blobbi'; - -/** Maximum number of d-tags per query chunk to avoid relay issues */ -const CHUNK_SIZE = 20; - -/** - * Split an array into chunks of a given size. - */ -function chunkArray(array: T[], size: number): T[][] { - const chunks: T[][] = []; - for (let i = 0; i < array.length; i += size) { - chunks.push(array.slice(i, i + size)); - } - return chunks; -} - -/** - * Hook to fetch ALL Blobbi companions (Kind 31124) owned by the logged-in user. - * - * Features: - * - Fetches ALL pets by d-tag list (no limit: 1) - * - Chunks large d-lists into multiple queries for relay compatibility - * - Keeps only the newest event per d-tag - * - Returns both a lookup record and array of companions - * - Provides invalidation and optimistic update helpers - */ -export function useBlobbisCollection(dList: string[] | undefined) { - const { nostr } = useNostr(); - const { user } = useCurrentUser(); - const queryClient = useQueryClient(); - - // Create a stable query key based on sorted d-tags - const sortedDList = useMemo(() => { - if (!dList || dList.length === 0) return null; - return [...dList].sort(); - }, [dList]); - - const queryKeyDTags = sortedDList?.join(',') ?? ''; - - // Main query to fetch all companions from relays - const query = useQuery({ - queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags], - queryFn: async ({ signal }) => { - if (!user?.pubkey || !sortedDList || sortedDList.length === 0) { - console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty'); - return { companionsByD: {}, companions: [] }; - } - - // Log the dList we're about to query - console.log('[Blobbi] dList:', sortedDList); - - // Chunk the d-list for relay compatibility - const chunks = chunkArray(sortedDList, CHUNK_SIZE); - console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)'); - - // Query all chunks in parallel - const allEvents: NostrEvent[] = []; - - for (const chunk of chunks) { - const filter = { - kinds: [KIND_BLOBBI_STATE], - authors: [user.pubkey], - '#d': chunk, - // IMPORTANT: No limit - fetch ALL pets matching the d-tags - }; - - // Log the filter immediately before query - console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2)); - - const events = await nostr.query([filter], { signal }); - allEvents.push(...events); - - console.log('[useBlobbisCollection] Chunk returned', events.length, 'events'); - } - - console.log('[useBlobbisCollection] Total events received:', allEvents.length); - - // Filter to valid events - const validEvents = allEvents.filter(isValidBlobbiEvent); - - console.log('[useBlobbisCollection] Valid events:', validEvents.length); - - // Group events by d-tag and keep only the newest per d - const eventsByD = new Map(); - - for (const event of validEvents) { - const dTag = event.tags.find(([name]) => name === 'd')?.[1]; - if (!dTag) continue; - - const existing = eventsByD.get(dTag); - if (!existing || event.created_at > existing.created_at) { - eventsByD.set(dTag, event); - } - } - - // Parse all events into BlobbiCompanion objects - const companionsByD: Record = {}; - const companions: BlobbiCompanion[] = []; - - for (const [dTag, event] of eventsByD) { - const parsed = parseBlobbiEvent(event); - if (parsed) { - companionsByD[dTag] = parsed; - companions.push(parsed); - } - } - - console.log('[useBlobbisCollection] Parsed companions:', { - count: companions.length, - dTags: Object.keys(companionsByD), - }); - - return { companionsByD, companions }; - }, - enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0, - staleTime: 30_000, // 30 seconds - gcTime: 5 * 60 * 1000, // 5 minutes - refetchOnWindowFocus: false, - refetchOnReconnect: true, - retry: 3, - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), - }); - - // Helper to invalidate and refetch after publishing - const invalidate = useCallback(() => { - if (user?.pubkey && queryKeyDTags) { - queryClient.invalidateQueries({ - queryKey: ['blobbi-collection', user.pubkey, queryKeyDTags], - }); - } - }, [queryClient, user?.pubkey, queryKeyDTags]); - - // Update a single companion event in the query cache (optimistic update) - const updateCompanionEvent = useCallback((event: NostrEvent) => { - const parsed = parseBlobbiEvent(event); - if (!parsed || !user?.pubkey) return; - - queryClient.setQueryData<{ companionsByD: Record; companions: BlobbiCompanion[] }>( - ['blobbi-collection', user.pubkey, queryKeyDTags], - (prev) => { - if (!prev) { - return { - companionsByD: { [parsed.d]: parsed }, - companions: [parsed], - }; - } - - // Update the specific companion in the record - const newCompanionsByD = { - ...prev.companionsByD, - [parsed.d]: parsed, - }; - - // Rebuild companions array from the record - const newCompanions = Object.values(newCompanionsByD); - - return { - companionsByD: newCompanionsByD, - companions: newCompanions, - }; - } - ); - }, [queryClient, user?.pubkey, queryKeyDTags]); - - // Memoize return values for stability - const companionsByD = query.data?.companionsByD ?? {}; - const companions = query.data?.companions ?? []; - - return { - /** Record of companions keyed by d-tag */ - companionsByD, - /** Array of all companions (newest per d-tag) */ - companions, - /** True only when query is loading and no data available */ - isLoading: query.isLoading, - /** True when actively fetching */ - isFetching: query.isFetching, - /** True when data is stale */ - isStale: query.isStale, - /** Query error if any */ - error: query.error, - /** Invalidate and refetch the collection */ - invalidate, - /** Optimistically update a single companion in the cache */ - updateCompanionEvent, - }; -} diff --git a/src/hooks/useBlobbonautProfileNormalization.ts b/src/hooks/useBlobbonautProfileNormalization.ts index bb31c278..78fc68d3 100644 --- a/src/hooks/useBlobbonautProfileNormalization.ts +++ b/src/hooks/useBlobbonautProfileNormalization.ts @@ -17,6 +17,7 @@ import { useNostrPublish } from './useNostrPublish'; import { KIND_BLOBBONAUT_PROFILE, profileNeedsPettingLevelNormalization, + profileNeedsOnboardingTagMigration, buildNormalizedProfileTags, isLegacyBlobbonautKind, type BlobbonautProfile, @@ -55,9 +56,10 @@ export function useBlobbonautProfileNormalization({ // Check what normalization is needed const needsTagNormalization = profileNeedsPettingLevelNormalization(profile); const needsKindMigration = isLegacyBlobbonautKind(profile.event); + const needsOnboardingMigration = profileNeedsOnboardingTagMigration(profile); // If no normalization needed, mark as seen and return - if (!needsTagNormalization && !needsKindMigration) { + if (!needsTagNormalization && !needsKindMigration && !needsOnboardingMigration) { normalizedEventIds.current.add(profile.event.id); return; } @@ -68,6 +70,7 @@ export function useBlobbonautProfileNormalization({ const reasons: string[] = []; if (needsTagNormalization) reasons.push('missing pettingLevel'); if (needsKindMigration) reasons.push('legacy kind 31125 → 11125'); + if (needsOnboardingMigration) reasons.push('onboarding_done → blobbi_onboarding_done'); console.log(`[ProfileNormalization] Profile needs normalization: ${reasons.join(', ')}`); diff --git a/src/hooks/useProjectedBlobbiState.ts b/src/hooks/useProjectedBlobbiState.ts deleted file mode 100644 index 7e9ebca3..00000000 --- a/src/hooks/useProjectedBlobbiState.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Hook for projecting Blobbi decay state in the UI. - * - * This hook provides a local projection of decay without publishing events. - * It recalculates every 60 seconds while the component is mounted. - * - * The projected state is for UI display only. Actual mutations must - * recalculate from the persisted state before publishing. - * - * @see docs/blobbi/decay-system.md - */ - -import { useState, useEffect, useMemo } from 'react'; - -import type { BlobbiCompanion, BlobbiStats } from '@/lib/blobbi'; -import { applyBlobbiDecay, getVisibleStatsWithValues, type DecayResult } from '@/lib/blobbi-decay'; - -/** UI refresh interval in milliseconds (60 seconds) */ -const UI_REFRESH_INTERVAL_MS = 60_000; - -/** - * Projected Blobbi state for UI display. - */ -export interface ProjectedBlobbiState { - /** Stats after applying projected decay */ - stats: BlobbiStats; - /** Visible stats for the current stage with status indicators */ - visibleStats: Array<{ - stat: keyof BlobbiStats; - value: number; - status: 'critical' | 'warning' | 'normal'; - }>; - /** Time elapsed since last decay (seconds) */ - elapsedSeconds: number; - /** Timestamp of the projection calculation */ - projectedAt: number; - /** Whether this is a fresh projection (recalculated this render) */ - isFresh: boolean; -} - -/** - * Hook to get a projected Blobbi state with decay applied. - * - * Features: - * - Immediately calculates projected state on mount/companion change - * - Recalculates every 60 seconds while mounted - * - Pure calculation - does not publish any events - * - Returns both full stats and stage-appropriate visible stats - * - * @param companion - The persisted Blobbi companion (source of truth) - * @returns Projected state with decay applied, or null if no companion - */ -export function useProjectedBlobbiState( - companion: BlobbiCompanion | null -): ProjectedBlobbiState | null { - // Track when we last recalculated - const [refreshTick, setRefreshTick] = useState(0); - - // Set up 60-second refresh interval - useEffect(() => { - if (!companion) return; - - const interval = setInterval(() => { - setRefreshTick(t => t + 1); - }, UI_REFRESH_INTERVAL_MS); - - return () => clearInterval(interval); - }, [companion]); - - // Calculate projected state - const projectedState = useMemo((): ProjectedBlobbiState | null => { - if (!companion) return null; - - const now = Math.floor(Date.now() / 1000); - - // Apply decay from persisted state - const decayResult: DecayResult = applyBlobbiDecay({ - stage: companion.stage, - state: companion.state, - stats: companion.stats, - lastDecayAt: companion.lastDecayAt, - now, - }); - - // Get visible stats for the stage - const visibleStats = getVisibleStatsWithValues(companion.stage, decayResult.stats); - - return { - stats: decayResult.stats, - visibleStats, - elapsedSeconds: decayResult.elapsedSeconds, - projectedAt: now, - isFresh: true, - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- refreshTick triggers recalculation - }, [companion, refreshTick]); - - return projectedState; -} - -/** - * Calculate projected decay for a companion at a specific timestamp. - * - * This is a utility function for use outside of React components, - * such as in mutation handlers before publishing. - * - * @param companion - The persisted Blobbi companion - * @param now - Unix timestamp to calculate decay to (defaults to current time) - * @returns Decay result with updated stats - */ -export function calculateProjectedDecay( - companion: BlobbiCompanion, - now?: number -): DecayResult { - return applyBlobbiDecay({ - stage: companion.stage, - state: companion.state, - stats: companion.stats, - lastDecayAt: companion.lastDecayAt, - now: now ?? Math.floor(Date.now() / 1000), - }); -} diff --git a/src/lib/blobbi-decay.ts b/src/lib/blobbi-decay.ts deleted file mode 100644 index 243fdb5d..00000000 --- a/src/lib/blobbi-decay.ts +++ /dev/null @@ -1,500 +0,0 @@ -/** - * Blobbi Decay System - * - * This module implements the continuous proportional decay system for Blobbi stats. - * - * Key principles: - * - Pure, deterministic calculation based on elapsed time - * - Floored stat changes before application - * - Stats clamped to 0-100 range - * - Stage-specific decay rates and health modifiers - * - Persisted state is the source of truth - * - * @see docs/blobbi/decay-system.md for full documentation - */ - -import type { BlobbiStage, BlobbiState, BlobbiStats } from './blobbi'; -import { STAT_MIN, STAT_MAX } from './blobbi'; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -/** - * Result of applying decay to a Blobbi. - * Contains updated stats and metadata about the calculation. - */ -export interface DecayResult { - /** Updated stats after decay (clamped to 0-100) */ - stats: BlobbiStats; - /** Elapsed time in seconds that was used for decay calculation */ - elapsedSeconds: number; - /** The timestamp that should be set as the new last_decay_at */ - newDecayTimestamp: number; -} - -/** - * Input parameters for decay calculation. - * Uses the persisted Blobbi state as source of truth. - */ -export interface DecayInput { - /** Current life stage */ - stage: BlobbiStage; - /** Current activity state (awake/sleeping) */ - state: BlobbiState; - /** Current stats from persisted state */ - stats: Partial; - /** Unix timestamp of last decay application */ - lastDecayAt: number | undefined; - /** Current unix timestamp (defaults to now) */ - now?: number; -} - -// ─── Constants: Decay Rates ─────────────────────────────────────────────────── - -/** - * Baby stage decay rates (per hour). - * - * Design goal: Needs attention every 3-5 hours. - */ -const BABY_DECAY = { - hunger: -7.0, - happiness: -4.0, - hygiene: -5.0, - energy: { - awake: -8.0, - sleeping: 6.0, // Regeneration - }, - health: { - base: -0.75, - hungerBelow70: -0.75, - hungerBelow40: -1.25, - hygieneBelow70: -0.75, - hygieneBelow40: -1.25, - energyBelow50: -0.5, - energyBelow25: -1.0, - happinessBelow50: -0.5, - happinessBelow25: -1.0, - // Regeneration when all stats are >= 80 - regenThreshold: 80, - regenRate: 1.5, - }, -} as const; - -/** - * Adult stage decay rates (per hour). - * - * Design goal: Needs attention every 5-7 hours. - */ -const ADULT_DECAY = { - hunger: -4.5, - happiness: -2.5, - hygiene: -3.5, - energy: { - awake: -5.0, - sleeping: 5.0, // Regeneration - }, - health: { - base: -0.4, - hungerBelow60: -0.5, - hungerBelow30: -1.0, - hygieneBelow60: -0.5, - hygieneBelow30: -1.0, - energyBelow40: -0.4, - energyBelow20: -0.8, - happinessBelow40: -0.4, - happinessBelow20: -0.8, - // Regeneration when all stats are >= 80 - regenThreshold: 80, - regenRate: 1.0, - }, -} as const; - -// ─── Constants: Warning Thresholds ──────────────────────────────────────────── - -/** - * Warning thresholds by stage. - * Warning = stat below this value indicates the Blobbi needs attention. - */ -export const WARNING_THRESHOLDS = { - egg: { - hygiene: 75, - health: 75, - happiness: 75, - }, - baby: { - hunger: 65, - happiness: 65, - hygiene: 65, - energy: 65, - health: 65, - }, - adult: { - hunger: 60, - happiness: 60, - hygiene: 60, - energy: 60, - health: 60, - }, -} as const; - -/** - * Critical thresholds by stage. - * Critical = stat below this value indicates urgent attention needed. - */ -export const CRITICAL_THRESHOLDS = { - egg: { - hygiene: 45, - health: 45, - happiness: 45, - }, - baby: { - hunger: 35, - happiness: 35, - hygiene: 35, - energy: 25, - health: 35, - }, - adult: { - hunger: 30, - happiness: 30, - hygiene: 30, - energy: 20, - health: 30, - }, -} as const; - -// ─── Helper Functions ───────────────────────────────────────────────────────── - -/** - * Clamp a value to the STAT_MIN-STAT_MAX range (1-100). - * Stats can never reach true zero - minimum is always 1. - */ -function clamp(value: number): number { - return Math.max(STAT_MIN, Math.min(STAT_MAX, value)); -} - -/** - * Get stat value with fallback to 100 (full). - */ -function getStat(stats: Partial, key: keyof BlobbiStats): number { - return stats[key] ?? 100; -} - -/** - * Convert hours to the elapsed time unit for calculation. - * @param hours - Elapsed hours - * @returns Rate multiplier for the elapsed time - */ -function hoursFromSeconds(seconds: number): number { - return seconds / 3600; -} - -// ─── Stage-Specific Decay Calculators ───────────────────────────────────────── - -/** - * Calculate egg stage decay. - * - * Eggs only decay hygiene, health, and happiness. - * Hunger and energy are fixed at 100. - */ -function calculateEggDecay( - stats: Partial, - _elapsedHours: number -): BlobbiStats { - // Eggs do not decay — all stats remain fixed until hatching. - return { - hunger: 100, - energy: 100, - hygiene: getStat(stats, 'hygiene'), - health: getStat(stats, 'health'), - happiness: getStat(stats, 'happiness'), - }; -} - -/** - * Calculate baby stage decay. - */ -function calculateBabyDecay( - stats: Partial, - state: BlobbiState, - elapsedHours: number -): BlobbiStats { - const isSleeping = state === 'sleeping'; - - // Get current values - let hunger = getStat(stats, 'hunger'); - let happiness = getStat(stats, 'happiness'); - let hygiene = getStat(stats, 'hygiene'); - let energy = getStat(stats, 'energy'); - let health = getStat(stats, 'health'); - - // Calculate basic stat decay/regen - const hungerDelta = BABY_DECAY.hunger * elapsedHours; - const happinessDelta = BABY_DECAY.happiness * elapsedHours; - const hygieneDelta = BABY_DECAY.hygiene * elapsedHours; - const energyDelta = (isSleeping ? BABY_DECAY.energy.sleeping : BABY_DECAY.energy.awake) * elapsedHours; - - // Apply basic deltas - hunger = clamp(hunger + Math.floor(hungerDelta)); - happiness = clamp(happiness + Math.floor(happinessDelta)); - hygiene = clamp(hygiene + Math.floor(hygieneDelta)); - energy = clamp(energy + Math.floor(energyDelta)); - - // Calculate health (complex conditional decay + possible regen) - let healthDelta = BABY_DECAY.health.base * elapsedHours; - - // Hunger penalties - if (hunger < 70) healthDelta += BABY_DECAY.health.hungerBelow70 * elapsedHours; - if (hunger < 40) healthDelta += BABY_DECAY.health.hungerBelow40 * elapsedHours; - - // Hygiene penalties - if (hygiene < 70) healthDelta += BABY_DECAY.health.hygieneBelow70 * elapsedHours; - if (hygiene < 40) healthDelta += BABY_DECAY.health.hygieneBelow40 * elapsedHours; - - // Energy penalties - if (energy < 50) healthDelta += BABY_DECAY.health.energyBelow50 * elapsedHours; - if (energy < 25) healthDelta += BABY_DECAY.health.energyBelow25 * elapsedHours; - - // Happiness penalties - if (happiness < 50) healthDelta += BABY_DECAY.health.happinessBelow50 * elapsedHours; - if (happiness < 25) healthDelta += BABY_DECAY.health.happinessBelow25 * elapsedHours; - - // Health regeneration (all stats >= 80) - const threshold = BABY_DECAY.health.regenThreshold; - if (hunger >= threshold && happiness >= threshold && hygiene >= threshold && energy >= threshold) { - healthDelta += BABY_DECAY.health.regenRate * elapsedHours; - } - - health = clamp(health + Math.floor(healthDelta)); - - return { hunger, happiness, hygiene, energy, health }; -} - -/** - * Calculate adult stage decay. - */ -function calculateAdultDecay( - stats: Partial, - state: BlobbiState, - elapsedHours: number -): BlobbiStats { - const isSleeping = state === 'sleeping'; - - // Get current values - let hunger = getStat(stats, 'hunger'); - let happiness = getStat(stats, 'happiness'); - let hygiene = getStat(stats, 'hygiene'); - let energy = getStat(stats, 'energy'); - let health = getStat(stats, 'health'); - - // Calculate basic stat decay/regen - const hungerDelta = ADULT_DECAY.hunger * elapsedHours; - const happinessDelta = ADULT_DECAY.happiness * elapsedHours; - const hygieneDelta = ADULT_DECAY.hygiene * elapsedHours; - const energyDelta = (isSleeping ? ADULT_DECAY.energy.sleeping : ADULT_DECAY.energy.awake) * elapsedHours; - - // Apply basic deltas - hunger = clamp(hunger + Math.floor(hungerDelta)); - happiness = clamp(happiness + Math.floor(happinessDelta)); - hygiene = clamp(hygiene + Math.floor(hygieneDelta)); - energy = clamp(energy + Math.floor(energyDelta)); - - // Calculate health (complex conditional decay + possible regen) - let healthDelta = ADULT_DECAY.health.base * elapsedHours; - - // Hunger penalties - if (hunger < 60) healthDelta += ADULT_DECAY.health.hungerBelow60 * elapsedHours; - if (hunger < 30) healthDelta += ADULT_DECAY.health.hungerBelow30 * elapsedHours; - - // Hygiene penalties - if (hygiene < 60) healthDelta += ADULT_DECAY.health.hygieneBelow60 * elapsedHours; - if (hygiene < 30) healthDelta += ADULT_DECAY.health.hygieneBelow30 * elapsedHours; - - // Energy penalties - if (energy < 40) healthDelta += ADULT_DECAY.health.energyBelow40 * elapsedHours; - if (energy < 20) healthDelta += ADULT_DECAY.health.energyBelow20 * elapsedHours; - - // Happiness penalties - if (happiness < 40) healthDelta += ADULT_DECAY.health.happinessBelow40 * elapsedHours; - if (happiness < 20) healthDelta += ADULT_DECAY.health.happinessBelow20 * elapsedHours; - - // Health regeneration (all stats >= 80) - const threshold = ADULT_DECAY.health.regenThreshold; - if (hunger >= threshold && happiness >= threshold && hygiene >= threshold && energy >= threshold) { - healthDelta += ADULT_DECAY.health.regenRate * elapsedHours; - } - - health = clamp(health + Math.floor(healthDelta)); - - return { hunger, happiness, hygiene, energy, health }; -} - -// ─── Main Decay Function ────────────────────────────────────────────────────── - -/** - * Apply decay to a Blobbi based on elapsed time since last decay. - * - * This is a pure, deterministic function that: - * 1. Calculates elapsed time from lastDecayAt to now - * 2. Applies stage-specific decay rates - * 3. Floors all stat deltas before application - * 4. Clamps final stats to 0-100 range - * 5. Returns updated stats without side effects - * - * @param input - Decay input parameters from persisted state - * @returns DecayResult with updated stats and new decay timestamp - */ -export function applyBlobbiDecay(input: DecayInput): DecayResult { - const now = input.now ?? Math.floor(Date.now() / 1000); - const lastDecayAt = input.lastDecayAt ?? now; - - // Calculate elapsed time - const elapsedSeconds = Math.max(0, now - lastDecayAt); - const elapsedHours = hoursFromSeconds(elapsedSeconds); - - // If no time has passed, return current stats unchanged - if (elapsedSeconds === 0) { - return { - stats: { - hunger: getStat(input.stats, 'hunger'), - happiness: getStat(input.stats, 'happiness'), - health: getStat(input.stats, 'health'), - hygiene: getStat(input.stats, 'hygiene'), - energy: getStat(input.stats, 'energy'), - }, - elapsedSeconds: 0, - newDecayTimestamp: now, - }; - } - - // Apply stage-specific decay - let newStats: BlobbiStats; - switch (input.stage) { - case 'egg': - newStats = calculateEggDecay(input.stats, elapsedHours); - break; - case 'baby': - newStats = calculateBabyDecay(input.stats, input.state, elapsedHours); - break; - case 'adult': - newStats = calculateAdultDecay(input.stats, input.state, elapsedHours); - break; - default: - // Fallback to adult decay for unknown stages - newStats = calculateAdultDecay(input.stats, input.state, elapsedHours); - } - - return { - stats: newStats, - elapsedSeconds, - newDecayTimestamp: now, - }; -} - -// ─── Threshold Checkers ─────────────────────────────────────────────────────── - -/** - * Check if a stat is at warning level for the given stage. - */ -export function isStatAtWarning( - stage: BlobbiStage, - stat: keyof BlobbiStats, - value: number -): boolean { - const thresholds = WARNING_THRESHOLDS[stage]; - const threshold = (thresholds as Record)[stat]; - if (threshold === undefined) return false; - return value < threshold; -} - -/** - * Check if a stat is at critical level for the given stage. - */ -export function isStatAtCritical( - stage: BlobbiStage, - stat: keyof BlobbiStats, - value: number -): boolean { - const thresholds = CRITICAL_THRESHOLDS[stage]; - const threshold = (thresholds as Record)[stat]; - if (threshold === undefined) return false; - return value < threshold; -} - -/** - * Get the status level for a stat. - * @returns 'critical' | 'warning' | 'normal' - */ -export function getStatStatus( - stage: BlobbiStage, - stat: keyof BlobbiStats, - value: number -): 'critical' | 'warning' | 'normal' { - if (isStatAtCritical(stage, stat, value)) return 'critical'; - if (isStatAtWarning(stage, stat, value)) return 'warning'; - return 'normal'; -} - -/** - * Get all stats that are at warning or critical level. - */ -export function getStatsNeedingAttention( - stage: BlobbiStage, - stats: Partial -): Array<{ stat: keyof BlobbiStats; value: number; status: 'warning' | 'critical' }> { - const results: Array<{ stat: keyof BlobbiStats; value: number; status: 'warning' | 'critical' }> = []; - - const statKeys: (keyof BlobbiStats)[] = ['hunger', 'happiness', 'health', 'hygiene', 'energy']; - - // For eggs, only check relevant stats - const relevantStats = stage === 'egg' - ? ['health', 'hygiene', 'happiness'] as (keyof BlobbiStats)[] - : statKeys; - - for (const stat of relevantStats) { - const value = stats[stat] ?? 100; - const status = getStatStatus(stage, stat, value); - if (status !== 'normal') { - results.push({ stat, value, status }); - } - } - - return results; -} - -// ─── Visible Stats Helper ───────────────────────────────────────────────────── - -/** - * Visibility threshold: stats at or above this value are hidden in the UI. - * Only stats below this threshold are displayed. - */ -export const STAT_VISIBILITY_THRESHOLD = 70; - -/** - * Get the stats that should be visible for a given stage. - * Eggs only show health, hygiene, happiness. - * Baby/adult show all stats. - */ -export function getVisibleStats(stage: BlobbiStage): (keyof BlobbiStats)[] { - if (stage === 'egg') { - return ['health', 'hygiene', 'happiness']; - } - return ['hunger', 'happiness', 'health', 'hygiene', 'energy']; -} - -/** - * Get visible stats with their values for display. - * Stats at or above STAT_VISIBILITY_THRESHOLD are filtered out. - */ -export function getVisibleStatsWithValues( - stage: BlobbiStage, - stats: Partial -): Array<{ stat: keyof BlobbiStats; value: number; status: 'critical' | 'warning' | 'normal' }> { - const visibleStats = getVisibleStats(stage); - return visibleStats - .map(stat => ({ - stat, - value: stats[stat] ?? 100, - status: getStatStatus(stage, stat, stats[stat] ?? 100), - })) - .filter(entry => entry.value < STAT_VISIBILITY_THRESHOLD); -} diff --git a/src/lib/blobbi-egg-adapter.ts b/src/lib/blobbi-egg-adapter.ts deleted file mode 100644 index f35bef7f..00000000 --- a/src/lib/blobbi-egg-adapter.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Blobbi → EggGraphic Adapter - * - * This module provides a translation layer between the Blobbi domain model - * and the portable EggGraphic visual module. - * - * PURPOSE: - * - Keep the game/domain visual model decoupled from EggGraphic internals - * - Provide explicit mappings between vocabularies - * - Act as the single translation boundary for visual rendering - * - * USAGE: - * ```ts - * const eggVisual = toEggGraphicVisualBlobbi(companion); - * // Pass eggVisual to EggGraphic component - * ``` - */ - -import type { EggVisualBlobbi } from '@/blobbi/egg'; -import { - type BlobbiCompanion, - type BlobbiPattern, - type BlobbiSpecialMark, - type BlobbiStage, - getTagValue, -} from './blobbi'; - -// ─── Egg Module Types (derived from EggVisualBlobbi) ────────────────────────── - -/** Life stage values accepted by EggGraphic */ -type EggLifeStage = NonNullable; - -/** Pattern values accepted by EggGraphic */ -type EggPattern = NonNullable; - -/** Special mark values accepted by EggGraphic */ -type EggSpecialMark = NonNullable; - -/** Theme variant values accepted by EggGraphic */ -type EggThemeVariant = NonNullable; - -// ─── Mapping Tables ─────────────────────────────────────────────────────────── - -/** - * Maps Blobbi pattern values to EggGraphic pattern values. - * Explicit mapping allows vocabularies to diverge in the future. - */ -const PATTERN_MAP: Record = { - 'solid': 'solid', - 'spotted': 'spotted', - 'striped': 'striped', - 'gradient': 'gradient', -}; - -/** - * Maps Blobbi special mark values to EggGraphic special mark values. - */ -const SPECIAL_MARK_MAP: Record = { - 'none': 'none', - 'star': 'star', - 'heart': 'heart', - 'sparkle': 'sparkle', - 'blush': 'blush', -}; - -/** - * Maps Blobbi stage values to EggGraphic life stage values. - */ -const LIFE_STAGE_MAP: Record = { - 'egg': 'egg', - 'baby': 'baby', - 'adult': 'adult', -}; - -// ─── Fallback Values ────────────────────────────────────────────────────────── - -const DEFAULT_PATTERN: EggPattern = 'solid'; -const DEFAULT_SPECIAL_MARK: EggSpecialMark = 'none'; -const DEFAULT_LIFE_STAGE: EggLifeStage = 'egg'; -const DEFAULT_THEME_VARIANT: EggThemeVariant = 'default'; - -// ─── Helper Functions ───────────────────────────────────────────────────────── - -/** - * Extract crossover app identifier from companion tags. - */ -function extractCrossoverApp(allTags: string[][]): string | undefined { - return getTagValue(allTags, 'crossover_app'); -} - -// ─── Main Adapter Function ──────────────────────────────────────────────────── - -/** - * Convert a BlobbiCompanion to EggVisualBlobbi for rendering. - * - * This is the TRANSLATION BOUNDARY between the Blobbi domain model - * and the EggGraphic visual module. - * - * The adapter: - * - Maps vocabulary values through explicit mapping tables - * - Passes through full tags for EggGraphic metadata lookups - * - Provides safe fallbacks for any missing/invalid data - * - Does NOT leak app-specific assumptions into EggGraphic - * - * @param companion - The parsed BlobbiCompanion from parseBlobbiEvent - * @param themeVariant - Optional theme variant override - * @returns Visual data compatible with EggVisualBlobbi - */ -export function toEggGraphicVisualBlobbi( - companion: BlobbiCompanion, - themeVariant: EggThemeVariant = DEFAULT_THEME_VARIANT -): EggVisualBlobbi { - const { visualTraits, stage, allTags } = companion; - - return { - // Colors pass through directly (already CSS hex values) - baseColor: visualTraits.baseColor, - secondaryColor: visualTraits.secondaryColor, - - // Mapped through explicit tables with fallbacks - pattern: PATTERN_MAP[visualTraits.pattern] ?? DEFAULT_PATTERN, - specialMark: SPECIAL_MARK_MAP[visualTraits.specialMark] ?? DEFAULT_SPECIAL_MARK, - lifeStage: LIFE_STAGE_MAP[stage] ?? DEFAULT_LIFE_STAGE, - - // Theme variant - themeVariant, - - // Pass through full tags for EggGraphic metadata lookups - tags: allTags, - - // Extracted convenience values - crossoverApp: extractCrossoverApp(allTags), - - // NOTE: We intentionally do NOT pass companion.name as title here. - // The EggGraphic 'title' field is for special designations (e.g., "Divine"), - // not the pet's name. The pet name is displayed separately by the parent component. - }; -} - -/** - * Check if two EggVisualBlobbi configurations are visually equivalent. - * Useful for memoization and avoiding unnecessary re-renders. - */ -export function areEggGraphicVisualsEqual( - a: EggVisualBlobbi, - b: EggVisualBlobbi -): boolean { - return ( - a.baseColor === b.baseColor && - a.secondaryColor === b.secondaryColor && - a.pattern === b.pattern && - a.specialMark === b.specialMark && - a.lifeStage === b.lifeStage && - a.themeVariant === b.themeVariant - ); -} diff --git a/src/lib/blobbi-tag-schema.ts b/src/lib/blobbi-tag-schema.ts deleted file mode 100644 index 809591ae..00000000 --- a/src/lib/blobbi-tag-schema.ts +++ /dev/null @@ -1,1120 +0,0 @@ -/** - * Blobbi Tag Schema - Canonical Reference - * - * This file defines the single source of truth for all Blobbi tags used in Kind 31124 - * (Blobbi State) events. It documents which tags exist, their purpose, when they're - * used, and how they should be handled during stage transitions. - * - * @module blobbi-tag-schema - */ - -import type { BlobbiStage } from './blobbi'; - -// ─── Tag Source Types ───────────────────────────────────────────────────────── - -/** - * Where the tag value originates from. - * - * - 'system': Generated by the system (d, b, t, client, seed) - * - 'generated': Derived deterministically from seed or other data - * - 'user': Set by user action or input - * - 'computed': Calculated from game state or events - */ -export type TagSource = 'system' | 'generated' | 'user' | 'computed'; - -/** - * Tag category for grouping related tags. - */ -export type TagCategory = - | 'system' // Protocol-level tags (d, b, t, client) - | 'identity' // Core identity (name, seed, generation) - | 'visual' // Visual traits (colors, pattern, size) - | 'personality' // Personality/traits (mood, favorite_food) - | 'stats' // Numeric stats (hunger, health, etc.) - | 'state' // Lifecycle state (stage, state, timestamps) - | 'progression' // Progress tracking (experience, care_streak) - | 'task' // Task system (task, task_completed, state_started_at) - | 'social' // Social flags (breeding_ready) - | 'evolution' // Evolution-specific (adult_type) - | 'extension'; // Extension tags (theme, crossover_app) - -// ─── Tag Schema Definition ──────────────────────────────────────────────────── - -/** - * Schema definition for a single Blobbi tag. - */ -export interface BlobbiTagSchema { - /** Tag name (the first element of the tag array) */ - tag: string; - - /** Human-readable description of what this tag represents */ - description: string; - - /** Category for grouping */ - category: TagCategory; - - /** Whether this tag is required for a valid Blobbi event */ - required: boolean; - - /** Which stages this tag should be present in */ - stages: BlobbiStage[]; - - /** Whether this tag persists across stage transitions (egg → baby → adult) */ - persistent: boolean; - - /** Where the tag value comes from */ - source: TagSource; - - /** Whether the tag can be regenerated from seed or must be preserved */ - regenerable: boolean; - - /** Expected format or valid values */ - format?: string; - - /** Default value if not present */ - defaultValue?: string; - - /** Notes about special handling or behavior */ - notes?: string; -} - -// ─── Canonical Tag Schema ───────────────────────────────────────────────────── - -/** - * Complete canonical schema for all valid Blobbi tags. - * - * This is the SINGLE SOURCE OF TRUTH for Blobbi tag definitions. - * All stage transitions, migrations, and validations should reference this schema. - */ -export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [ - // ═══════════════════════════════════════════════════════════════════════════ - // SYSTEM / METADATA TAGS - // ═══════════════════════════════════════════════════════════════════════════ - { - tag: 'd', - description: 'Unique identifier for the Blobbi (addressable event d-tag)', - category: 'system', - required: true, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'system', - regenerable: false, - format: 'blobbi-{pubkeyPrefix12}-{petId10}', - notes: 'Canonical format required. Legacy formats trigger migration.', - }, - { - tag: 'b', - description: 'Ecosystem namespace identifier', - category: 'system', - required: true, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'system', - regenerable: true, - format: 'blobbi:ecosystem:v1', - defaultValue: 'blobbi:ecosystem:v1', - }, - // ═══════════════════════════════════════════════════════════════════════════ - // CORE IDENTITY TAGS - // ═══════════════════════════════════════════════════════════════════════════ - { - tag: 'name', - description: 'Display name for the Blobbi', - category: 'identity', - required: true, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'user', - regenerable: false, - notes: 'Set during adoption. Can be changed by user. Must be preserved across stages.', - }, - { - tag: 'seed', - description: '64-character hex seed for deterministic visual trait generation', - category: 'identity', - required: true, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'system', - regenerable: false, - format: '64 lowercase hex characters', - notes: 'Derived once at creation: sha256("blobbi:v1|{pubkey}:{d}:{createdAt}"). MUST NOT be recomputed.', - }, - { - tag: 'generation', - description: 'Generation number in the Blobbi lineage', - category: 'identity', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'system', - regenerable: false, - format: 'positive integer', - defaultValue: '1', - notes: 'Starts at 1 for adopted Blobbis. Increments for bred offspring.', - }, - - // ═══════════════════════════════════════════════════════════════════════════ - // VISUAL TRAIT TAGS - // ═══════════════════════════════════════════════════════════════════════════ - { - tag: 'base_color', - description: 'Primary/base color for the Blobbi', - category: 'visual', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'generated', - regenerable: true, - format: 'CSS hex color (e.g., #F59E0B)', - notes: 'Derived from seed. Stored explicitly for fast rendering and legacy compatibility.', - }, - { - tag: 'secondary_color', - description: 'Secondary/accent color for the Blobbi', - category: 'visual', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'generated', - regenerable: true, - format: 'CSS hex color (e.g., #FCD34D)', - notes: 'Derived from seed.', - }, - { - tag: 'eye_color', - description: 'Eye color for the Blobbi', - category: 'visual', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'generated', - regenerable: true, - format: 'CSS hex color (e.g., #1F2937)', - notes: 'Derived from seed.', - }, - { - tag: 'pattern', - description: 'Visual pattern type', - category: 'visual', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'generated', - regenerable: true, - format: 'solid | spotted | striped | gradient', - notes: 'Derived from seed.', - }, - { - tag: 'special_mark', - description: 'Special decorative marking', - category: 'visual', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'generated', - regenerable: true, - format: 'none | star | heart | sparkle | blush', - notes: 'Derived from seed.', - }, - { - tag: 'size', - description: 'Size category', - category: 'visual', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'generated', - regenerable: true, - format: 'small | medium | large', - notes: 'Derived from seed.', - }, - - // ═══════════════════════════════════════════════════════════════════════════ - // PERSONALITY / TRAIT TAGS - // ═══════════════════════════════════════════════════════════════════════════ - { - tag: 'personality', - description: 'Core personality type that influences behavior', - category: 'personality', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'generated', - regenerable: false, - notes: 'Generated at creation. MUST persist across all stage transitions.', - }, - { - tag: 'trait', - description: 'Character trait modifier', - category: 'personality', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'generated', - regenerable: false, - notes: 'Generated at creation. MUST persist across all stage transitions.', - }, - { - tag: 'favorite_food', - description: 'Preferred food type (affects happiness from feeding)', - category: 'personality', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'generated', - regenerable: false, - notes: 'Generated at creation. MUST persist across all stage transitions.', - }, - { - tag: 'voice_type', - description: 'Voice characteristic for audio feedback', - category: 'personality', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'generated', - regenerable: false, - notes: 'Generated at creation. MUST persist across all stage transitions.', - }, - { - tag: 'mood', - description: 'Current emotional state', - category: 'personality', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'computed', - regenerable: false, - notes: 'May change based on stats and interactions. Persists across stages.', - }, - - // ═══════════════════════════════════════════════════════════════════════════ - // STAT TAGS - // ═══════════════════════════════════════════════════════════════════════════ - { - tag: 'hunger', - description: 'Hunger level (higher = more full)', - category: 'stats', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: false, - source: 'computed', - regenerable: false, - format: '1-100 integer', - defaultValue: '100', - notes: 'Decays over time. Eggs have fixed value of 100.', - }, - { - tag: 'happiness', - description: 'Happiness level', - category: 'stats', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: false, - source: 'computed', - regenerable: false, - format: '1-100 integer', - defaultValue: '100', - notes: 'Affected by interactions and care quality.', - }, - { - tag: 'health', - description: 'Health level', - category: 'stats', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: false, - source: 'computed', - regenerable: false, - format: '1-100 integer', - defaultValue: '100', - notes: 'Eggs inherit health to baby. Baby inherits health to adult.', - }, - { - tag: 'hygiene', - description: 'Hygiene/cleanliness level', - category: 'stats', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: false, - source: 'computed', - regenerable: false, - format: '1-100 integer', - defaultValue: '100', - notes: 'Decays over time. Affects health if too low.', - }, - { - tag: 'energy', - description: 'Energy level', - category: 'stats', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: false, - source: 'computed', - regenerable: false, - format: '1-100 integer', - defaultValue: '100', - notes: 'Restored by sleeping. Eggs have fixed value of 100.', - }, - - // ═══════════════════════════════════════════════════════════════════════════ - // STATE / LIFECYCLE TAGS - // ═══════════════════════════════════════════════════════════════════════════ - { - tag: 'stage', - description: 'Current lifecycle stage', - category: 'state', - required: true, - stages: ['egg', 'baby', 'adult'], - persistent: false, - source: 'system', - regenerable: false, - format: 'egg | baby | adult', - notes: 'Changes during hatch (egg→baby) and evolve (baby→adult).', - }, - { - tag: 'state', - description: 'Current activity state', - category: 'state', - required: true, - stages: ['egg', 'baby', 'adult'], - persistent: false, - source: 'system', - regenerable: false, - format: 'active | sleeping | hibernating | incubating | evolving', - notes: 'incubating is for eggs, evolving is for babies. Reset to active after transition.', - }, - { - tag: 'last_interaction', - description: 'Unix timestamp of last user interaction', - category: 'state', - required: true, - stages: ['egg', 'baby', 'adult'], - persistent: false, - source: 'system', - regenerable: false, - format: 'Unix timestamp (seconds)', - notes: 'Updated on any user action. Used for activity tracking.', - }, - { - tag: 'last_decay_at', - description: 'Unix timestamp for stat decay checkpoint', - category: 'state', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: false, - source: 'system', - regenerable: false, - format: 'Unix timestamp (seconds)', - notes: 'Used to calculate accumulated decay. Reset after applying decay.', - }, - - // ═══════════════════════════════════════════════════════════════════════════ - // TASK SYSTEM TAGS - // ═══════════════════════════════════════════════════════════════════════════ - { - tag: 'state_started_at', - description: 'Unix timestamp when current state (incubating/evolving) started', - category: 'task', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: false, - source: 'system', - regenerable: false, - format: 'Unix timestamp (seconds)', - notes: 'Set when entering incubating/evolving. REMOVED after hatch/evolve completes.', - }, - { - tag: 'task', - description: 'Task progress tracking', - category: 'task', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: false, - source: 'computed', - regenerable: false, - format: '["task", "taskName:progressValue"]', - notes: 'Multiple task tags allowed. REMOVED after stage transition completes.', - }, - { - tag: 'task_completed', - description: 'Completed task marker', - category: 'task', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: false, - source: 'computed', - regenerable: false, - format: '["task_completed", "taskName"]', - notes: 'Multiple allowed. REMOVED after stage transition completes.', - }, - - // ═══════════════════════════════════════════════════════════════════════════ - // PROGRESSION TAGS - // ═══════════════════════════════════════════════════════════════════════════ - { - tag: 'experience', - description: 'Total experience points accumulated', - category: 'progression', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'computed', - regenerable: false, - format: 'non-negative integer', - defaultValue: '0', - notes: 'Accumulates across all stages. Never resets.', - }, - { - tag: 'care_streak', - description: 'Consecutive days of care', - category: 'progression', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'computed', - regenerable: false, - format: 'positive integer', - defaultValue: '1', - notes: 'Starts at 1 on first activity. Increments when activity occurs on the next calendar day. Resets to 1 if 2+ days are missed. Persists across stages.', - }, - { - tag: 'care_streak_last_at', - description: 'Unix timestamp (seconds) of last streak update', - category: 'progression', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'computed', - regenerable: false, - format: 'unix timestamp (seconds)', - defaultValue: undefined, - notes: 'Stores when the streak was last updated. Used alongside care_streak_last_day for debugging and validation.', - }, - { - tag: 'care_streak_last_day', - description: 'Local calendar day (YYYY-MM-DD) of last streak update', - category: 'progression', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'computed', - regenerable: false, - format: 'YYYY-MM-DD', - defaultValue: undefined, - notes: 'Stores the local calendar day when streak was last counted. Used to prevent double-counting on the same day and to calculate days missed.', - }, - - // ═══════════════════════════════════════════════════════════════════════════ - // SOCIAL / FLAG TAGS - // ═══════════════════════════════════════════════════════════════════════════ - { - tag: 'breeding_ready', - description: 'Whether the Blobbi is eligible for breeding', - category: 'social', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'computed', - regenerable: false, - format: 'true | false', - defaultValue: 'false', - notes: 'Typically only true for healthy adults.', - }, - - // ═══════════════════════════════════════════════════════════════════════════ - // EVOLUTION TAGS - // ═══════════════════════════════════════════════════════════════════════════ - { - tag: 'adult_type', - description: 'Adult evolution form type', - category: 'evolution', - required: false, - stages: ['adult'], - persistent: true, - source: 'computed', - regenerable: false, - notes: 'Only present for adults. Determined during evolution based on care history.', - }, - - // ═══════════════════════════════════════════════════════════════════════════ - // EXTENSION TAGS (Optional, for crossovers/themes) - // ═══════════════════════════════════════════════════════════════════════════ - { - tag: 'theme', - description: 'Theme variant identifier', - category: 'extension', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'system', - regenerable: false, - format: 'Theme identifier string (e.g., "divine")', - notes: 'Used for special themed Blobbis. Persists across stages.', - }, - { - tag: 'crossover_app', - description: 'Crossover application identifier', - category: 'extension', - required: false, - stages: ['egg', 'baby', 'adult'], - persistent: true, - source: 'system', - regenerable: false, - format: 'Application identifier string (e.g., "divine")', - notes: 'Indicates Blobbi originated from or has features from another app.', - }, -] as const; - -// ─── Deprecated Tags ────────────────────────────────────────────────────────── - -/** - * Schema for deprecated tags that should be removed when republishing. - * These tags were part of earlier designs but are no longer used. - */ -export interface DeprecatedTagSchema { - /** Tag name */ - tag: string; - /** Why this tag was deprecated */ - reason: string; - /** What replaced it (if applicable) */ - replacedBy?: string; - /** When it was deprecated (version or date) */ - deprecatedSince: string; -} - -/** - * List of deprecated tags that should be filtered out during republishing. - */ -export const DEPRECATED_TAG_SCHEMA: readonly DeprecatedTagSchema[] = [ - { - tag: 't', - reason: 'Topic tag no longer needed - the app uses the b namespace tag for identification', - replacedBy: undefined, - deprecatedSince: 'v2.0', - }, - { - tag: 'client', - reason: 'Client tag no longer needed - useNostrPublish adds this automatically', - replacedBy: undefined, - deprecatedSince: 'v2.0', - }, - { - tag: 'shell_integrity', - reason: 'Eggs now use the standard health stat instead of a separate shell metric', - replacedBy: 'health', - deprecatedSince: 'v1.0', - }, - { - tag: 'egg_temperature', - reason: 'Egg warmth is handled via UI props, not persisted state', - replacedBy: undefined, - deprecatedSince: 'v1.0', - }, - { - tag: 'incubation_progress', - reason: 'Replaced by task-based incubation system', - replacedBy: 'task, task_completed', - deprecatedSince: 'v1.0', - }, - { - tag: 'egg_status', - reason: 'Replaced by standard state field', - replacedBy: 'state', - deprecatedSince: 'v1.0', - }, - { - tag: 'fees', - reason: 'Fee tracking removed from Blobbi events', - replacedBy: undefined, - deprecatedSince: 'v1.0', - }, - { - tag: 'incubation_time', - reason: 'Task system uses state_started_at for timing', - replacedBy: 'state_started_at', - deprecatedSince: 'v1.0', - }, - { - tag: 'start_incubation', - reason: 'Task system uses state_started_at for timing', - replacedBy: 'state_started_at', - deprecatedSince: 'v1.0', - }, - { - tag: 'interact_6_progress', - reason: 'Legacy interaction tracking from old system', - replacedBy: '["task", "interactions:N"]', - deprecatedSince: 'v1.1', - }, -] as const; - -// ─── Helper Functions ───────────────────────────────────────────────────────── - -/** - * Get all tag names that should persist across stage transitions. - */ -export function getPersistentTagNames(): Set { - return new Set( - BLOBBI_TAG_SCHEMA - .filter(schema => schema.persistent) - .map(schema => schema.tag) - ); -} - -/** - * Get all tag names that should be removed during stage transitions. - * These are task-related and state-specific tags. - */ -export function getTransitionCleanupTagNames(): Set { - return new Set(['task', 'task_completed', 'state_started_at']); -} - -/** - * Get all deprecated tag names. - */ -export function getDeprecatedTagNames(): Set { - return new Set(DEPRECATED_TAG_SCHEMA.map(schema => schema.tag)); -} - -/** - * Get all required tag names. - */ -export function getRequiredTagNames(): Set { - return new Set( - BLOBBI_TAG_SCHEMA - .filter(schema => schema.required) - .map(schema => schema.tag) - ); -} - -/** - * Get tag schema by tag name. - */ -export function getTagSchema(tagName: string): BlobbiTagSchema | undefined { - return BLOBBI_TAG_SCHEMA.find(schema => schema.tag === tagName); -} - -/** - * Get all tags for a specific stage. - */ -export function getTagsForStage(stage: BlobbiStage): BlobbiTagSchema[] { - return BLOBBI_TAG_SCHEMA.filter(schema => schema.stages.includes(stage)); -} - -/** - * Get all tags in a specific category. - */ -export function getTagsByCategory(category: TagCategory): BlobbiTagSchema[] { - return BLOBBI_TAG_SCHEMA.filter(schema => schema.category === category); -} - -/** - * Validate that an event has all required tags. - */ -export function validateRequiredTags(tags: string[][]): { valid: boolean; missing: string[] } { - const requiredTags = getRequiredTagNames(); - const presentTags = new Set(tags.map(tag => tag[0])); - const missing = [...requiredTags].filter(tag => !presentTags.has(tag)); - - return { - valid: missing.length === 0, - missing, - }; -} - -// ─── Tag Integrity Guard ────────────────────────────────────────────────────── - -/** - * Result of tag validation and repair. - */ -export interface TagRepairResult { - /** The repaired tags (ready for publishing) */ - tags: string[][]; - /** Whether any repairs were made */ - repaired: boolean; - /** List of repairs that were made */ - repairs: string[]; - /** List of issues that could not be repaired */ - errors: string[]; - /** The detected final stage */ - finalStage: BlobbiStage | null; -} - -/** - * System-required tags that can be recovered with default values. - * These are protocol-level tags that have well-defined defaults. - */ -const RECOVERABLE_SYSTEM_TAGS: Record = { - b: 'blobbi:ecosystem:v1', -}; - -/** - * Tags that should NEVER be invented if they don't exist. - * Per spec: Do NOT invent personality/trait/adult_type for existing Blobbis. - */ -const NEVER_INVENT_TAGS = new Set([ - // Personality tags - generated at creation only - 'personality', - 'trait', - 'favorite_food', - 'voice_type', - 'mood', - // Evolution tags - computed during evolve only - 'adult_type', - // Extension tags - set by specific features only - 'theme', - 'crossover_app', - // Identity tags that are user-set or derived once - 'name', - 'seed', - 'd', -]); - -/** - * Valid states for each stage. Used to validate/repair state after transitions. - */ -const VALID_STATES_BY_STAGE: Record> = { - egg: new Set(['active', 'sleeping', 'hibernating', 'incubating']), - baby: new Set(['active', 'sleeping', 'hibernating', 'evolving']), - adult: new Set(['active', 'sleeping', 'hibernating']), -}; - -/** - * Task-process states that should NOT remain after stage transitions. - */ -const TASK_PROCESS_STATES = new Set(['incubating', 'evolving']); - -/** - * Validate and repair Blobbi tags before publishing. - * - * This function ensures tag integrity by: - * 1. Removing deprecated tags - * 2. Removing task tags when cleanupTaskTags is true - * 3. Stage-aware validation: removes tags not valid for the final stage - * 4. Semantic cleanup: ensures state is valid after transitions - * 5. Recovering missing required system tags (b, t, client) with defaults - * 6. Recovering missing required tags from previousTags if available - * 7. Preserving persistent tags that are valid for the final stage - * 8. NEVER inventing personality/trait/adult_type values - * - * @param tags - The current tags to validate and repair - * @param previousTags - Optional previous canonical tags for recovery - * @param options - Optional configuration for repair behavior - * @returns TagRepairResult with repaired tags and repair log - * - * @example - * ```typescript - * const result = validateAndRepairBlobbiTags(newTags, canonical.allTags, { cleanupTaskTags: true }); - * if (result.errors.length > 0) { - * console.error('Cannot publish:', result.errors); - * return; - * } - * await publishEvent({ kind: KIND_BLOBBI_STATE, tags: result.tags, ... }); - * ``` - */ -export function validateAndRepairBlobbiTags( - tags: string[][], - previousTags?: string[][], - options?: { - /** If true, removes task-related tags (for stage transitions) */ - cleanupTaskTags?: boolean; - } -): TagRepairResult { - const repairs: string[] = []; - const errors: string[] = []; - const isDev = import.meta.env.DEV; - - // Build a map of current tags for quick lookup - const tagMap = new Map(); - for (const tag of tags) { - const name = tag[0]; - if (!tagMap.has(name)) { - tagMap.set(name, []); - } - tagMap.get(name)!.push(tag); - } - - // Build a map of previous tags for recovery - const previousTagMap = new Map(); - if (previousTags) { - for (const tag of previousTags) { - // For single-value tags, just store the last value - // Multi-value tags (task, task_completed) are handled specially - if (tag[0] !== 'task' && tag[0] !== 'task_completed') { - previousTagMap.set(tag[0], tag[1]); - } - } - } - - // Get deprecated and task cleanup tag sets - const deprecatedTags = getDeprecatedTagNames(); - const taskCleanupTags = options?.cleanupTaskTags ? getTransitionCleanupTagNames() : new Set(); - - // ─── Step 1: Filter out deprecated tags and task tags (if cleanup requested) ─── - let filteredTags: string[][] = []; - for (const tag of tags) { - const name = tag[0]; - - if (deprecatedTags.has(name)) { - repairs.push(`Removed deprecated tag: ${name}`); - continue; - } - - if (taskCleanupTags.has(name)) { - repairs.push(`Removed task tag during transition: ${name}`); - continue; - } - - filteredTags.push(tag); - } - - // ─── Step 2: Detect the final stage ─── - const stageTag = filteredTags.find(t => t[0] === 'stage'); - const finalStage = (stageTag?.[1] as BlobbiStage) || null; - - if (!finalStage || !['egg', 'baby', 'adult'].includes(finalStage)) { - errors.push(`Invalid or missing stage tag: '${finalStage}'`); - // Cannot proceed with stage-aware validation without a valid stage - return { - tags: filteredTags, - repaired: repairs.length > 0, - repairs, - errors, - finalStage: null, - }; - } - - // ─── Step 3: Stage-aware tag filtering ─── - // Remove tags that are not valid for the final stage - const stageFilteredTags: string[][] = []; - - for (const tag of filteredTags) { - const name = tag[0]; - - // Check if this tag is valid for the current stage - const schema = getTagSchema(name); - if (schema && !schema.stages.includes(finalStage)) { - repairs.push(`Removed tag '${name}' (not valid for stage '${finalStage}')`); - if (isDev) { - console.warn(`[Blobbi] Removed invalid-for-stage tag: ${name} (stage: ${finalStage})`); - } - continue; - } - - stageFilteredTags.push(tag); - } - - filteredTags = stageFilteredTags; - - // ─── Step 4: Semantic cleanup - validate state after transitions ─── - if (options?.cleanupTaskTags) { - const stateTagIndex = filteredTags.findIndex(t => t[0] === 'state'); - if (stateTagIndex !== -1) { - const currentState = filteredTags[stateTagIndex][1]; - - // After hatch/evolve, state must not be a task-process state - if (TASK_PROCESS_STATES.has(currentState)) { - filteredTags[stateTagIndex] = ['state', 'active']; - repairs.push(`Repaired state from '${currentState}' to 'active' (task process completed)`); - if (isDev) { - console.warn(`[Blobbi] Fixed invalid state '${currentState}' -> 'active' after transition`); - } - } - - // Validate state is valid for the stage - const validStates = VALID_STATES_BY_STAGE[finalStage]; - const newState = filteredTags[stateTagIndex][1]; - if (!validStates.has(newState)) { - filteredTags[stateTagIndex] = ['state', 'active']; - repairs.push(`Repaired invalid state '${newState}' to 'active' for stage '${finalStage}'`); - if (isDev) { - console.warn(`[Blobbi] Fixed invalid state '${newState}' -> 'active' for stage ${finalStage}`); - } - } - } - } - - // Rebuild tag map after filtering - tagMap.clear(); - for (const tag of filteredTags) { - const name = tag[0]; - if (!tagMap.has(name)) { - tagMap.set(name, []); - } - tagMap.get(name)!.push(tag); - } - - // ─── Step 5: Check for required tags and attempt recovery ─── - const requiredTags = getRequiredTagNames(); - const repairedTags = [...filteredTags]; - - for (const requiredTag of requiredTags) { - // Skip required tags that aren't valid for this stage - const schema = getTagSchema(requiredTag); - if (schema && !schema.stages.includes(finalStage)) { - continue; - } - - if (tagMap.has(requiredTag)) { - // Tag exists, validate it's not empty - const tagValue = tagMap.get(requiredTag)![0]?.[1]; - if (!tagValue || tagValue.trim() === '') { - // Try to recover from previous tags or defaults - const recovered = recoverTagValue(requiredTag, previousTagMap); - if (recovered !== null) { - // Remove the empty tag and add recovered value - const index = repairedTags.findIndex(t => t[0] === requiredTag); - if (index !== -1) { - repairedTags[index] = [requiredTag, recovered]; - const source = previousTagMap.has(requiredTag) ? 'previous tags' : 'defaults'; - repairs.push(`Repaired empty ${requiredTag} tag from ${source}`); - } - } else { - errors.push(`Required tag '${requiredTag}' is empty and cannot be recovered`); - } - } - } else { - // Tag is missing, attempt recovery - const recovered = recoverTagValue(requiredTag, previousTagMap); - if (recovered !== null) { - repairedTags.push([requiredTag, recovered]); - const source = previousTagMap.has(requiredTag) ? 'previous tags' : 'defaults'; - repairs.push(`Recovered missing ${requiredTag} tag from ${source}`); - } else { - errors.push(`Required tag '${requiredTag}' is missing and cannot be recovered`); - } - } - } - - // ─── Step 6: Recover persistent tags from previous tags (if they existed before) ─── - // Only recover tags that are valid for the final stage - // NEVER invent values for tags in NEVER_INVENT_TAGS - if (previousTags) { - const persistentTags = getPersistentTagNames(); - const currentTagNames = new Set(repairedTags.map(t => t[0])); - - for (const [tagName, tagValue] of previousTagMap.entries()) { - // Skip if already present in current tags - if (currentTagNames.has(tagName)) continue; - - // Skip non-persistent tags - if (!persistentTags.has(tagName)) continue; - - // Skip task-related tags if cleanup is requested - if (taskCleanupTags.has(tagName)) continue; - - // Skip tags that are not valid for the final stage - const schema = getTagSchema(tagName); - if (schema && !schema.stages.includes(finalStage)) { - if (isDev) { - console.warn(`[Blobbi] Skipped recovering '${tagName}' (not valid for stage '${finalStage}')`); - } - continue; - } - - // Recover the persistent tag - repairedTags.push([tagName, tagValue]); - repairs.push(`Recovered persistent tag: ${tagName}`); - } - } - - // ─── Final dev diagnostics ─── - if (isDev && repairs.length > 0) { - console.warn('[Blobbi] Tag repairs applied:', repairs); - } - - return { - tags: repairedTags, - repaired: repairs.length > 0, - repairs, - errors, - finalStage, - }; -} - -/** - * Attempt to recover a tag value from previous tags or system defaults. - * - * Recovery strategy: - * 1. If tag exists in previousTags, use that value - * 2. If tag is a recoverable system tag, use the default - * 3. Otherwise, return null (cannot recover) - * - * NEVER invents values for personality/trait/adult_type tags. - */ -function recoverTagValue( - tagName: string, - previousTagMap: Map -): string | null { - // First, try to recover from previous tags - if (previousTagMap.has(tagName)) { - const value = previousTagMap.get(tagName)!; - if (value && value.trim() !== '') { - return value; - } - } - - // Never invent certain tags - if (NEVER_INVENT_TAGS.has(tagName)) { - return null; - } - - // Try system defaults for recoverable tags - if (tagName in RECOVERABLE_SYSTEM_TAGS) { - return RECOVERABLE_SYSTEM_TAGS[tagName]; - } - - return null; -} - -/** - * Check if tags pass validation without repairing. - * Use this for quick validation checks. - */ -export function isValidBlobbiTagSet(tags: string[][]): boolean { - const result = validateAndRepairBlobbiTags(tags); - return result.errors.length === 0; -} - -// ─── Schema Summary for Documentation ───────────────────────────────────────── - -/** - * Generate a summary of the tag schema grouped by category. - * Useful for documentation and debugging. - */ -export function generateSchemaDocumentation(): string { - const categories = [ - 'system', 'identity', 'visual', 'personality', 'stats', - 'state', 'task', 'progression', 'social', 'evolution', 'extension', - ] as TagCategory[]; - - let doc = '# Blobbi Tag Schema\n\n'; - - for (const category of categories) { - const tags = getTagsByCategory(category); - if (tags.length === 0) continue; - - doc += `## ${category.charAt(0).toUpperCase() + category.slice(1)} Tags\n\n`; - doc += '| Tag | Required | Stages | Persistent | Source | Description |\n'; - doc += '|-----|----------|--------|------------|--------|-------------|\n'; - - for (const tag of tags) { - const stages = tag.stages.join(', '); - doc += `| \`${tag.tag}\` | ${tag.required ? 'Yes' : 'No'} | ${stages} | ${tag.persistent ? 'Yes' : 'No'} | ${tag.source} | ${tag.description} |\n`; - } - - doc += '\n'; - } - - doc += '## Deprecated Tags\n\n'; - doc += '| Tag | Reason | Replaced By |\n'; - doc += '|-----|--------|-------------|\n'; - - for (const tag of DEPRECATED_TAG_SCHEMA) { - doc += `| \`${tag.tag}\` | ${tag.reason} | ${tag.replacedBy ?? 'N/A'} |\n`; - } - - return doc; -} diff --git a/src/lib/blobbi.ts b/src/lib/blobbi.ts deleted file mode 100644 index 2c0a7791..00000000 --- a/src/lib/blobbi.ts +++ /dev/null @@ -1,1568 +0,0 @@ -import { sha256 } from '@noble/hashes/sha256'; -import { bytesToHex } from '@noble/hashes/utils'; -import type { NostrEvent } from '@nostrify/nostrify'; - -import { validateAndRepairBlobbiTags } from './blobbi-tag-schema'; - -// ─── Constants ──────────────────────────────────────────────────────────────── - -export const BLOBBI_ECOSYSTEM_NAMESPACE = 'blobbi:ecosystem:v1'; - -export const KIND_BLOBBI_STATE = 31124; -export const KIND_BLOBBONAUT_PROFILE = 11125; - -/** @deprecated Legacy kind for Blobbonaut profiles. Use KIND_BLOBBONAUT_PROFILE (11125) instead. */ -export const KIND_BLOBBONAUT_PROFILE_LEGACY = 31125; - -/** All Blobbonaut profile kinds to query (for migration support) */ -export const BLOBBONAUT_PROFILE_KINDS = [KIND_BLOBBONAUT_PROFILE, KIND_BLOBBONAUT_PROFILE_LEGACY] as const; - -// ─── Stat Bounds ────────────────────────────────────────────────────────────── - -/** - * Minimum stat value - stats can never go below this. - * The minimum of 1 (instead of 0) ensures: - * - Blobbi is never in an unrecoverable state - * - Visual feedback shows critical state without being "dead" - * - Recovery is always possible with any healing item - */ -export const STAT_MIN = 1; - -/** - * Maximum stat value - stats can never exceed this. - */ -export const STAT_MAX = 100; - -// Default stats for a new egg -export const DEFAULT_EGG_STATS = { - hunger: 100, - happiness: 100, - health: 100, - hygiene: 100, - energy: 100, -}; - -/** - * @deprecated No longer used. Task system uses state_started_at instead. - * Kept for backwards compatibility with older code that may reference it. - */ -export const DEFAULT_INCUBATION_TIME = 345600; - -// ─── Onboarding Constants ───────────────────────────────────────────────────── - -/** Initial coins given to new Blobbonauts */ -export const INITIAL_BLOBBONAUT_COINS = 200; - -/** Cost to reroll/generate another egg preview during onboarding */ -export const BLOBBI_PREVIEW_REROLL_COST = 10; - -/** Cost to adopt a Blobbi from the preview */ -export const BLOBBI_ADOPTION_COST = 100; - -// ─── Date/Time Utilities ────────────────────────────────────────────────────── - -/** - * Get the current local day as a YYYY-MM-DD string. - * Uses the user's local timezone for day boundary calculation. - */ -export function getLocalDayString(date: Date = new Date()): string { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -} - -/** - * Parse a YYYY-MM-DD string into a Date object (at midnight local time). - */ -export function parseLocalDayString(dayString: string): Date { - const [year, month, day] = dayString.split('-').map(Number); - return new Date(year, month - 1, day); -} - -/** - * Get the number of days between two local day strings. - * Returns 0 if same day, 1 if consecutive days, etc. - */ -export function getDaysDifference(dayA: string, dayB: string): number { - const dateA = parseLocalDayString(dayA); - const dateB = parseLocalDayString(dayB); - const diffMs = Math.abs(dateB.getTime() - dateA.getTime()); - return Math.floor(diffMs / (1000 * 60 * 60 * 24)); -} - -// ─── Types ──────────────────────────────────────────────────────────────────── - -export type BlobbiStage = 'egg' | 'baby' | 'adult'; -export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving'; - -export interface BlobbiStats { - hunger: number; - happiness: number; - health: number; - hygiene: number; - energy: number; -} - -// ─── Visual Traits Types ────────────────────────────────────────────────────── - -/** - * Visual traits for a Blobbi, derived from seed or legacy tags. - * - * This interface is designed to be directly consumable by the EggGraphic module. - * All color values are canonical CSS hex colors. - * All categorical values match the EggGraphic vocabulary. - */ -export interface BlobbiVisualTraits { - /** Primary/base color - hex value (e.g., "#F59E0B") */ - baseColor: string; - /** Secondary/accent color - hex value */ - secondaryColor: string; - /** Eye color - hex value */ - eyeColor: string; - /** Pattern type: 'solid' | 'spotted' | 'striped' | 'gradient' */ - pattern: BlobbiPattern; - /** Special marking: 'none' | 'star' | 'heart' | 'sparkle' | 'blush' */ - specialMark: BlobbiSpecialMark; - /** Size category: 'small' | 'medium' | 'large' */ - size: BlobbiSize; -} - -/** Pattern types supported by EggGraphic */ -export type BlobbiPattern = 'solid' | 'spotted' | 'striped' | 'gradient'; - -/** Special marks supported by EggGraphic */ -export type BlobbiSpecialMark = 'none' | 'star' | 'heart' | 'sparkle' | 'blush'; - -/** Size categories supported by EggGraphic */ -export type BlobbiSize = 'small' | 'medium' | 'large'; - -/** - * Base color palette - canonical hex values. - * These are carefully chosen to look good on egg shapes. - */ -export const BLOBBI_BASE_COLORS: readonly string[] = [ - '#F59E0B', // Amber/Gold - '#55C4A2', // Teal - '#60A5FA', // Sky Blue - '#F472B6', // Pink - '#A78BFA', // Purple - '#F87171', // Coral Red - '#34D399', // Emerald - '#FBBF24', // Yellow - '#818CF8', // Indigo - '#FB923C', // Orange -] as const; - -/** - * Secondary color palette - complementary/accent hex values. - */ -export const BLOBBI_SECONDARY_COLORS: readonly string[] = [ - '#FCD34D', // Light Gold - '#6EE7B7', // Light Teal - '#93C5FD', // Light Blue - '#F9A8D4', // Light Pink - '#C4B5FD', // Light Purple - '#FCA5A5', // Light Coral - '#A7F3D0', // Light Emerald - '#FDE68A', // Light Yellow - '#A5B4FC', // Light Indigo - '#FDBA74', // Light Orange -] as const; - -/** - * Eye color palette - expressive hex values. - */ -export const BLOBBI_EYE_COLORS: readonly string[] = [ - '#1F2937', // Dark Gray (default) - '#7C3AED', // Violet - '#059669', // Emerald - '#DC2626', // Red - '#2563EB', // Blue - '#D97706', // Amber - '#DB2777', // Pink - '#4F46E5', // Indigo -] as const; - -/** Available patterns - EggGraphic compatible */ -export const BLOBBI_PATTERNS: readonly BlobbiPattern[] = [ - 'solid', - 'spotted', - 'striped', - 'gradient', -] as const; - -/** Available special marks - EggGraphic compatible */ -export const BLOBBI_SPECIAL_MARKS: readonly BlobbiSpecialMark[] = [ - 'none', - 'star', - 'heart', - 'sparkle', - 'blush', -] as const; - -/** Available sizes - EggGraphic compatible */ -export const BLOBBI_SIZES: readonly BlobbiSize[] = [ - 'small', - 'medium', - 'large', -] as const; - -/** Default visual traits when seed is missing */ -export const DEFAULT_VISUAL_TRAITS: BlobbiVisualTraits = { - baseColor: '#F59E0B', - secondaryColor: '#FCD34D', - eyeColor: '#1F2937', - pattern: 'solid', - specialMark: 'none', - size: 'medium', -} as const; - -/** - * Parsed task progress stored in Blobbi event tags. - * Format: ["task", "name:value"] - */ -export interface BlobbiTaskProgress { - name: string; - value: number; -} - -/** - * Parsed representation of a Kind 31124 Blobbi Current State event. - */ -export interface BlobbiCompanion { - /** Original event for republishing */ - event: NostrEvent; - /** The d tag value */ - d: string; - /** Display name */ - name: string; - /** Lifecycle stage */ - stage: BlobbiStage; - /** Activity state */ - state: BlobbiState; - /** Deterministic identity seed (64-char hex) */ - seed: string | undefined; - /** Visual traits (derived from seed or legacy tags) */ - visualTraits: BlobbiVisualTraits; - /** Whether this is a legacy event that needs migration */ - isLegacy: boolean; - /** Timestamp of last user interaction (unix seconds) */ - lastInteraction: number; - /** Timestamp used for stat decay checkpoint (unix seconds) */ - lastDecayAt: number | undefined; - /** Stats (0-100) */ - stats: Partial; - /** Generation number */ - generation: number | undefined; - /** Breeding eligibility */ - breedingReady: boolean; - /** Total XP */ - experience: number | undefined; - /** Consecutive care days */ - careStreak: number | undefined; - /** Unix timestamp (seconds) of last streak update */ - careStreakLastAt: number | undefined; - /** Local day string (YYYY-MM-DD) of last streak update */ - careStreakLastDay: string | undefined; - /** - * @deprecated Incubation time in seconds - no longer used. - * Task system uses state_started_at instead. - */ - incubationTime: number | undefined; - /** - * @deprecated When incubation began - no longer used. - * Replaced by state_started_at for all process timing. - */ - startIncubation: number | undefined; - /** Adult evolution form type (adult only) */ - adultType: string | undefined; - /** Timestamp when current state (incubating/evolving) started (unix seconds) */ - stateStartedAt: number | undefined; - /** Task progress cache (source of truth is computed from Nostr events) */ - tasks: BlobbiTaskProgress[]; - /** Completed task names */ - tasksCompleted: string[]; - /** All tags preserved for republishing */ - allTags: string[][]; -} - -/** - * Stored item in user's profile inventory - */ -export interface StorageItem { - itemId: string; // Must match a ShopItem.id - quantity: number; // Must be >= 1 -} - -/** - * Parsed representation of a Blobbonaut Profile event (Kind 11125). - * Also supports legacy Kind 31125 profiles. - */ -export interface BlobbonautProfile { - /** Original event for republishing */ - event: NostrEvent; - /** The d tag value */ - d: string; - /** Currently selected companion Blobbi d-tag */ - currentCompanion: string | undefined; - /** Whether onboarding/tutorial is complete */ - onboardingDone: boolean; - /** Display name for the Blobbonaut */ - name: string | undefined; - /** List of owned Blobbi d-tags */ - has: string[]; - /** In-game currency balance */ - coins: number; - /** Petting level (interaction counter) */ - pettingLevel: number; - /** Purchased items inventory */ - storage: StorageItem[]; - /** All tags preserved for republishing */ - allTags: string[][]; -} - -// ─── Helper Functions ───────────────────────────────────────────────────────── - -/** - * Get the first 12 lowercase hex characters from a pubkey. - */ -export function getPubkeyPrefix12(pubkey: string): string { - return pubkey.slice(0, 12).toLowerCase(); -} - -/** - * Generate a random 10-character lowercase hex petId. - */ -export function generatePetId10(): string { - const bytes = new Uint8Array(5); - crypto.getRandomValues(bytes); - return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); -} - -/** - * Get the canonical d-tag for a Blobbi (Kind 31124). - * Format: blobbi-{ownerPubkeyPrefix12}-{petId10} - */ -export function getCanonicalBlobbiD(pubkey: string, petId: string): string { - return `blobbi-${getPubkeyPrefix12(pubkey)}-${petId}`; -} - -/** - * Get the canonical d-tag for a Blobbonaut Profile (Kind 11125). - * Format: blobbonaut-{pubkeyPrefix12} - */ -export function getCanonicalBlobbonautD(pubkey: string): string { - return `blobbonaut-${getPubkeyPrefix12(pubkey)}`; -} - -/** - * Derive the Blobbi seed using sha256. - * seed = sha256("blobbi:v1|" + pubkey + ":" + d + ":" + createdAt) - * - * This is the raw derivation function. Use getOrDeriveSeed() when working with events - * to ensure existing seeds are never recomputed. - */ -export function deriveBlobbiSeedV1(pubkey: string, d: string, createdAt: number): string { - const input = `blobbi:v1|${pubkey}:${d}:${createdAt}`; - const hashBytes = sha256(new TextEncoder().encode(input)); - return bytesToHex(hashBytes); -} - -/** - * Get the seed from an existing event, or derive it if not present. - * Per spec: Clients MUST NOT recompute the seed if a seed tag already exists. - * - * @param event - The Blobbi event to get/derive seed from - * @returns The existing seed or a newly derived one - */ -export function getOrDeriveSeed(event: NostrEvent): string { - const existingSeed = getTagValue(event.tags, 'seed'); - if (existingSeed && existingSeed.length === 64) { - return existingSeed; - } - - const d = getTagValue(event.tags, 'd'); - if (!d) { - throw new Error('Cannot derive seed: event missing d tag'); - } - - return deriveBlobbiSeedV1(event.pubkey, d, event.created_at); -} - -// ─── Tag Parsing Utilities ──────────────────────────────────────────────────── - -/** - * Get the first value for a given tag name. - * Does NOT assume tag order. - */ -export function getTagValue(tags: string[][], name: string): string | undefined { - const tag = tags.find(([n]) => n === name); - return tag?.[1]; -} - -/** - * Get all values for a given tag name (for repeated tags like "has"). - */ -export function getTagValues(tags: string[][], name: string): string[] { - return tags.filter(([n]) => n === name).map(t => t[1]).filter(Boolean); -} - -/** - * Parse a numeric tag value, returning undefined if invalid. - */ -function parseNumericTag(tags: string[][], name: string): number | undefined { - const value = getTagValue(tags, name); - if (value === undefined) return undefined; - const num = parseInt(value, 10); - return isNaN(num) ? undefined : num; -} - -/** - * Parse a boolean tag value (string "true" or "false"). - */ -function parseBooleanTag(tags: string[][], name: string, defaultValue = false): boolean { - const value = getTagValue(tags, name); - if (value === 'true') return true; - if (value === 'false') return false; - return defaultValue; -} - -/** - * Parse storage tags from a Blobbonaut Profile event (Kind 11125). - * Storage tags format: ['storage', 'itemId:quantity'] - * - * @param tags - Event tags array - * @returns Array of storage items with itemId and quantity - */ -export function parseStorageTags(tags: string[][]): StorageItem[] { - return tags - .filter(tag => tag[0] === 'storage') - .map(tag => { - const [itemId, quantityStr] = tag[1].split(':'); - return { - itemId, - quantity: parseInt(quantityStr, 10), - }; - }) - .filter(item => item.itemId && !isNaN(item.quantity) && item.quantity > 0); -} - -/** - * Create storage tags from storage items array. - * Each item becomes: ['storage', 'itemId:quantity'] - * - * @param storage - Array of storage items - * @returns Array of storage tags - */ -export function createStorageTags(storage: StorageItem[]): string[][] { - return storage - .filter(item => item.itemId && item.quantity > 0) - .map(item => ['storage', `${item.itemId}:${item.quantity}`]); -} - -// ─── Legacy Detection ───────────────────────────────────────────────────────── - -/** - * Check if a Blobbonaut d-tag is in canonical format. - * Canonical: blobbonaut-{12 lowercase hex} - */ -export function isCanonicalBlobbonautD(d: string): boolean { - return /^blobbonaut-[0-9a-f]{12}$/.test(d); -} - -/** - * Check if a Blobbonaut d-tag is a legacy format. - * Legacy formats: - * - Blobbonaut-{8-12 hex} (capitalized) - * - blobbonaut-profile - * - blobbonaut-{8-11 hex} - */ -export function isLegacyBlobbonautD(d: string): boolean { - // Capitalized version - if (/^Blobbonaut-[0-9a-fA-F]{8,12}$/.test(d)) return true; - // Generic profile id - if (d === 'blobbonaut-profile') return true; - // Short prefix (8-11 chars instead of 12) - if (/^blobbonaut-[0-9a-f]{8,11}$/.test(d)) return true; - return false; -} - -/** - * Check if a Blobbi d-tag is in canonical format. - * Canonical: blobbi-{12 lowercase hex}-{10 lowercase hex} - * Per spec: petId MUST be 10 lowercase hex characters - */ -export function isCanonicalBlobbiD(d: string): boolean { - return /^blobbi-[0-9a-f]{12}-[0-9a-f]{10}$/.test(d); -} - -/** - * Check if a Blobbi d-tag is a legacy format (e.g., blobbi-puck, blobbi-fluffy). - */ -export function isLegacyBlobbiD(d: string): boolean { - // Legacy: blobbi-{name} where name is NOT the canonical format - if (!d.startsWith('blobbi-')) return false; - if (isCanonicalBlobbiD(d)) return false; - return true; -} - -// ─── Visual Trait Derivation ────────────────────────────────────────────────── - -/** - * Derive a numeric value from a seed at a specific offset. - * Uses 4 bytes (8 hex chars) starting at offset to create a deterministic number. - * - * Seed offset layout (per spec): - * - [0..8] base_color - * - [8..16] secondary_color / eye_color - * - [16..24] pattern - * - [24..32] special_mark - * - [32..40] size - */ -function deriveIndexFromSeed(seed: string, offset: number, max: number): number { - const slice = seed.slice(offset, offset + 8); - const value = parseInt(slice, 16); - return Math.abs(value) % max; -} - -/** - * Derive base color (hex) from seed. - */ -export function deriveBaseColorFromSeed(seed: string): string { - const index = deriveIndexFromSeed(seed, 0, BLOBBI_BASE_COLORS.length); - return BLOBBI_BASE_COLORS[index]; -} - -/** - * Derive secondary color (hex) from seed. - */ -export function deriveSecondaryColorFromSeed(seed: string): string { - const index = deriveIndexFromSeed(seed, 8, BLOBBI_SECONDARY_COLORS.length); - return BLOBBI_SECONDARY_COLORS[index]; -} - -/** - * Derive eye color (hex) from seed. - */ -export function deriveEyeColorFromSeed(seed: string): string { - const index = deriveIndexFromSeed(seed, 12, BLOBBI_EYE_COLORS.length); - return BLOBBI_EYE_COLORS[index]; -} - -/** - * Derive pattern from seed. - */ -export function derivePatternFromSeed(seed: string): BlobbiPattern { - const index = deriveIndexFromSeed(seed, 16, BLOBBI_PATTERNS.length); - return BLOBBI_PATTERNS[index]; -} - -/** - * Derive special mark from seed. - */ -export function deriveSpecialMarkFromSeed(seed: string): BlobbiSpecialMark { - const index = deriveIndexFromSeed(seed, 24, BLOBBI_SPECIAL_MARKS.length); - return BLOBBI_SPECIAL_MARKS[index]; -} - -/** - * Derive size from seed. - */ -export function deriveSizeFromSeed(seed: string): BlobbiSize { - const index = deriveIndexFromSeed(seed, 32, BLOBBI_SIZES.length); - return BLOBBI_SIZES[index]; -} - -/** - * Validate and normalize a pattern value from a tag. - * Returns undefined if invalid, allowing fallback to seed derivation. - */ -function normalizePatternTag(value: string | undefined): BlobbiPattern | undefined { - if (!value) return undefined; - const normalized = value.toLowerCase() as BlobbiPattern; - return BLOBBI_PATTERNS.includes(normalized) ? normalized : undefined; -} - -/** - * Validate and normalize a special mark value from a tag. - * Returns undefined if invalid, allowing fallback to seed derivation. - */ -function normalizeSpecialMarkTag(value: string | undefined): BlobbiSpecialMark | undefined { - if (!value) return undefined; - const normalized = value.toLowerCase() as BlobbiSpecialMark; - return BLOBBI_SPECIAL_MARKS.includes(normalized) ? normalized : undefined; -} - -/** - * Validate and normalize a size value from a tag. - * Returns undefined if invalid, allowing fallback to seed derivation. - */ -function normalizeSizeTag(value: string | undefined): BlobbiSize | undefined { - if (!value) return undefined; - const normalized = value.toLowerCase() as BlobbiSize; - return BLOBBI_SIZES.includes(normalized) ? normalized : undefined; -} - -/** - * Validate a hex color value. - * Returns the value if valid hex, undefined otherwise. - */ -function normalizeHexColor(value: string | undefined): string | undefined { - if (!value) return undefined; - // Accept both #RGB and #RRGGBB formats - if (/^#[0-9A-Fa-f]{3}$/.test(value) || /^#[0-9A-Fa-f]{6}$/.test(value)) { - return value.toUpperCase(); - } - return undefined; -} - -/** - * Derive all visual traits from seed, with legacy tag fallbacks. - * - * ┌─────────────────────────────────────────────────────────────────────────────┐ - * │ VISUAL TRAIT POLICY │ - * │ │ - * │ Trait resolution priority (per field): │ - * │ 1. Explicit valid tags → always take precedence if present │ - * │ 2. Derive from seed → primary source for canonical events │ - * │ 3. Safe defaults → final fallback when both tag and seed are missing │ - * │ │ - * │ IMPORTANT: Legacy events may have explicit tags WITHOUT a seed. │ - * │ These tags must be respected - do NOT discard them in favor of defaults. │ - * │ │ - * │ New canonical events should rely on seed for visual derivation. │ - * │ Legacy tags are preserved for backwards compatibility. │ - * └─────────────────────────────────────────────────────────────────────────────┘ - * - * This function is the SINGLE SOURCE OF TRUTH for visual trait resolution. - * The UI should consume the output directly without additional logic. - */ -export function deriveVisualTraits( - tags: string[][], - seed: string | undefined -): BlobbiVisualTraits { - // Step 1: Extract and validate explicit tag values - // These always take precedence if present and valid - const tagBaseColor = normalizeHexColor(getTagValue(tags, 'base_color')); - const tagSecondaryColor = normalizeHexColor(getTagValue(tags, 'secondary_color')); - const tagEyeColor = normalizeHexColor(getTagValue(tags, 'eye_color')); - const tagPattern = normalizePatternTag(getTagValue(tags, 'pattern')); - const tagSpecialMark = normalizeSpecialMarkTag(getTagValue(tags, 'special_mark')); - const tagSize = normalizeSizeTag(getTagValue(tags, 'size')); - - // Step 2: Determine fallback values (seed-derived or defaults) - const hasSeed = seed && seed.length === 64; - - // Resolve baseColor first (needed for secondaryColor fallback) - const fallbackBaseColor = hasSeed ? deriveBaseColorFromSeed(seed) : DEFAULT_VISUAL_TRAITS.baseColor; - const resolvedBaseColor = tagBaseColor ?? fallbackBaseColor; - - // Secondary color: if no seed, fall back to resolved baseColor for unified palette - // This ensures legacy events with only base_color don't get a mismatched yellow accent - const fallbackSecondaryColor = hasSeed ? deriveSecondaryColorFromSeed(seed) : resolvedBaseColor; - - const fallbackEyeColor = hasSeed ? deriveEyeColorFromSeed(seed) : DEFAULT_VISUAL_TRAITS.eyeColor; - const fallbackPattern = hasSeed ? derivePatternFromSeed(seed) : DEFAULT_VISUAL_TRAITS.pattern; - const fallbackSpecialMark = hasSeed ? deriveSpecialMarkFromSeed(seed) : DEFAULT_VISUAL_TRAITS.specialMark; - const fallbackSize = hasSeed ? deriveSizeFromSeed(seed) : DEFAULT_VISUAL_TRAITS.size; - - // Step 3: Priority: explicit valid tag > fallback (seed-derived or default) - return { - baseColor: resolvedBaseColor, - secondaryColor: tagSecondaryColor ?? fallbackSecondaryColor, - eyeColor: tagEyeColor ?? fallbackEyeColor, - pattern: tagPattern ?? fallbackPattern, - specialMark: tagSpecialMark ?? fallbackSpecialMark, - size: tagSize ?? fallbackSize, - }; -} - -// ─── Legacy Event Detection ─────────────────────────────────────────────────── - -/** - * Check if a Blobbi event is a legacy event that needs migration. - * - * A Blobbi is considered legacy if ANY of the following is true: - * - the d tag is not in canonical format - * - the seed tag is missing - * - the name tag is missing and must be derived from d - * - visual traits exist but seed does not - * - * Canonical Blobbi events must always contain: - * - canonical d - * - seed - * - name - * - stage - * - state - * - stats - * - ecosystem tag - */ -export function isLegacyBlobbiEvent(event: NostrEvent): boolean { - const tags = event.tags; - const d = getTagValue(tags, 'd'); - - if (!d) return true; - - // Check if d-tag is not canonical - if (!isCanonicalBlobbiD(d)) { - return true; - } - - // Check if seed is missing - const seed = getTagValue(tags, 'seed'); - if (!seed || seed.length !== 64) { - return true; - } - - // Check if name tag is missing - const name = getTagValue(tags, 'name'); - if (!name) { - return true; - } - - // Check if visual traits exist but seed does not - // (This case is already covered by seed check above, but being explicit) - const hasVisualTags = getTagValue(tags, 'base_color') !== undefined || - getTagValue(tags, 'pattern') !== undefined || - getTagValue(tags, 'special_mark') !== undefined || - getTagValue(tags, 'size') !== undefined; - - if (hasVisualTags && !seed) { - return true; - } - - return false; -} - -/** - * Check if a parsed BlobbiCompanion needs migration. - * This is a convenience wrapper around isLegacyBlobbiEvent. - */ -export function companionNeedsMigration(companion: BlobbiCompanion): boolean { - return companion.isLegacy; -} - -// ─── Event Validation ───────────────────────────────────────────────────────── - -/** - * Validate that an event has the required tags for a valid Blobbi state (Kind 31124). - * Required: d, b (blobbi:ecosystem:v1), stage, state, last_interaction - */ -export function isValidBlobbiEvent(event: NostrEvent): boolean { - if (event.kind !== KIND_BLOBBI_STATE) return false; - - const d = getTagValue(event.tags, 'd'); - const b = getTagValue(event.tags, 'b'); - const stage = getTagValue(event.tags, 'stage'); - const state = getTagValue(event.tags, 'state'); - const lastInteraction = getTagValue(event.tags, 'last_interaction'); - - if (!d) return false; - if (b !== BLOBBI_ECOSYSTEM_NAMESPACE) return false; - if (!stage || !['egg', 'baby', 'adult'].includes(stage)) return false; - if (!state || !['active', 'sleeping', 'hibernating', 'incubating', 'evolving'].includes(state)) return false; - if (!lastInteraction) return false; - - return true; -} - -/** - * Validate that an event has the required tags for a valid Blobbonaut profile. - * Accepts both current kind (11125) and legacy kind (31125) for migration support. - * Required: d, b (blobbi:ecosystem:v1) - */ -export function isValidBlobbonautEvent(event: NostrEvent): boolean { - // Accept both current and legacy kinds - if (event.kind !== KIND_BLOBBONAUT_PROFILE && event.kind !== KIND_BLOBBONAUT_PROFILE_LEGACY) { - return false; - } - - const d = getTagValue(event.tags, 'd'); - const b = getTagValue(event.tags, 'b'); - - if (!d) return false; - if (b !== BLOBBI_ECOSYSTEM_NAMESPACE) return false; - - return true; -} - -/** - * Check if a Blobbonaut profile event is using the legacy kind (31125). - * Used to determine if migration is needed. - */ -export function isLegacyBlobbonautKind(event: NostrEvent): boolean { - return event.kind === KIND_BLOBBONAUT_PROFILE_LEGACY; -} - -// ─── Event Parsing ──────────────────────────────────────────────────────────── - -/** - * Derive a display name from a legacy d-tag. - * Legacy format: blobbi-{name} (e.g., "blobbi-puck" → "Puck") - * - * @param d - The d-tag value - * @returns The derived name with first letter capitalized, or "Unnamed Blobbi" if not derivable - */ -/** - * Capitalize each word in a string. - * @example "mr cool" -> "Mr Cool" - */ -function capitalizeWords(str: string): string { - return str - .split(' ') - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(' '); -} - -/** - * Derive a display name from a legacy d-tag. - * - * Transformation rules: - * 1. Remove "blobbi-" prefix - * 2. Replace "-" and "_" with spaces - * 3. Trim whitespace - * 4. Capitalize words in a human-friendly way - * 5. Fallback to "Unnamed Blobbi" if result is empty - * - * @example "blobbi-puck" -> "Puck" - * @example "blobbi-mr-cool" -> "Mr Cool" - * @example "blobbi_blue" -> "Blue" - * @example "blobbi-" -> "Unnamed Blobbi" - */ -export function deriveNameFromLegacyD(d: string): string { - if (!d.startsWith('blobbi-')) { - return 'Unnamed Blobbi'; - } - - // Remove prefix and normalize separators - const rawName = d - .replace('blobbi-', '') - .replace(/[-_]/g, ' ') - .trim(); - - // If nothing meaningful remains, return fallback - if (!rawName || rawName.length === 0) { - return 'Unnamed Blobbi'; - } - - // Capitalize words for human-friendly display - return capitalizeWords(rawName); -} - -/** - * Parse a Kind 31124 Blobbi Current State event into a structured object. - * Returns undefined if the event is invalid. - * - * This function is the SINGLE SOURCE OF TRUTH for resolving: - * - name (from tag or legacy d-tag derivation) - * - seed - * - visualTraits (derived from seed, with legacy tag fallbacks) - * - isLegacy flag - * - * The UI should NOT need to guess names or traits - everything is resolved here. - * - * Name resolution priority: - * 1. Use `name` tag if present - * 2. Derive from legacy d-tag format (blobbi-{name}) - * 3. Fall back to "Unnamed Blobbi" - * - * Visual trait priority: - * 1. Use explicit visual tags if valid (legacy compatibility) - * 2. Derive deterministically from seed - * 3. Use safe defaults if seed is missing - */ -export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined { - if (!isValidBlobbiEvent(event)) return undefined; - - const tags = event.tags; - const d = getTagValue(tags, 'd')!; - const nameTag = getTagValue(tags, 'name'); - const stage = getTagValue(tags, 'stage') as BlobbiStage; - const state = getTagValue(tags, 'state') as BlobbiState; - const seed = getTagValue(tags, 'seed'); - - // Resolve name: tag > legacy d-tag derivation > fallback - const name = nameTag ?? deriveNameFromLegacyD(d); - - // Derive visual traits (single source of truth) - const visualTraits = deriveVisualTraits(tags, seed); - - // Check if this is a legacy event that needs migration - const isLegacy = isLegacyBlobbiEvent(event); - - // Concise, structured debug log - console.log('[Blobbi]', { - d: d.length > 30 ? `${d.slice(0, 20)}...` : d, - name, - isLegacy, - hasSeed: !!seed, - traits: `${visualTraits.baseColor} ${visualTraits.pattern} ${visualTraits.size}`, - }); - - // Parse task progress tags: ["task", "name:value"] - const tasks: BlobbiTaskProgress[] = []; - for (const tag of tags) { - if (tag[0] === 'task' && tag[1]) { - const [taskName, taskValue] = tag[1].split(':'); - if (taskName && taskValue) { - tasks.push({ name: taskName, value: parseInt(taskValue, 10) || 0 }); - } - } - } - - // Parse completed task tags: ["task_completed", "name"] - const tasksCompleted: string[] = []; - for (const tag of tags) { - if (tag[0] === 'task_completed' && tag[1]) { - tasksCompleted.push(tag[1]); - } - } - - return { - event, - d, - name, - stage, - state, - seed, - visualTraits, - isLegacy, - lastInteraction: parseNumericTag(tags, 'last_interaction')!, - lastDecayAt: parseNumericTag(tags, 'last_decay_at'), - stats: { - hunger: parseNumericTag(tags, 'hunger'), - happiness: parseNumericTag(tags, 'happiness'), - health: parseNumericTag(tags, 'health'), - hygiene: parseNumericTag(tags, 'hygiene'), - energy: parseNumericTag(tags, 'energy'), - }, - generation: parseNumericTag(tags, 'generation'), - breedingReady: parseBooleanTag(tags, 'breeding_ready', false), - experience: parseNumericTag(tags, 'experience'), - careStreak: parseNumericTag(tags, 'care_streak'), - careStreakLastAt: parseNumericTag(tags, 'care_streak_last_at'), - careStreakLastDay: getTagValue(tags, 'care_streak_last_day'), - incubationTime: parseNumericTag(tags, 'incubation_time'), - startIncubation: parseNumericTag(tags, 'start_incubation'), - adultType: getTagValue(tags, 'adult_type'), - stateStartedAt: parseNumericTag(tags, 'state_started_at'), - tasks, - tasksCompleted, - allTags: tags, - }; -} - -/** - * Parse a Kind 11125 Blobbonaut Profile event into a structured object. - * Also supports legacy kind 31125 profiles for migration purposes. - * Returns undefined if the event is invalid. - * - * Note: pettingLevel is parsed from both 'pettingLevel' and 'petting_level' tags - * for backwards compatibility with legacy profiles. - */ -export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | undefined { - if (!isValidBlobbonautEvent(event)) return undefined; - - const tags = event.tags; - const d = getTagValue(tags, 'd')!; - - // Parse pettingLevel from either camelCase or snake_case tag - const pettingLevelValue = parseNumericTag(tags, 'pettingLevel') - ?? parseNumericTag(tags, 'petting_level') - ?? 0; - - return { - event, - d, - currentCompanion: getTagValue(tags, 'current_companion'), - onboardingDone: parseBooleanTag(tags, 'onboarding_done', false), - name: getTagValue(tags, 'name'), - has: getTagValues(tags, 'has'), - coins: parseNumericTag(tags, 'coins') ?? 0, - pettingLevel: pettingLevelValue, - storage: parseStorageTags(tags), - allTags: tags, - }; -} - -// ─── Tag Building Utilities ─────────────────────────────────────────────────── - -/** - * Build tags for a new Blobbonaut Profile (Kind 11125). - * Includes pettingLevel: 0 by default. - */ -export function buildBlobbonautTags(pubkey: string): string[][] { - return [ - ['d', getCanonicalBlobbonautD(pubkey)], - ['b', BLOBBI_ECOSYSTEM_NAMESPACE], - ['onboarding_done', 'false'], - ['pettingLevel', '0'], - ]; -} - -/** - * Build tags for a new Blobbi egg (Kind 31124). - * Includes required and recommended tags for a new egg. - * - * Visual traits are derived from the seed and explicitly stored - * to ensure consistent rendering across clients. - */ -export function buildEggTags( - pubkey: string, - petId: string, - createdAt: number, - name = 'Egg' -): string[][] { - const d = getCanonicalBlobbiD(pubkey, petId); - const seed = deriveBlobbiSeedV1(pubkey, d, createdAt); - const now = createdAt.toString(); - - // Derive visual traits from seed for explicit storage - const baseColor = deriveBaseColorFromSeed(seed); - const secondaryColor = deriveSecondaryColorFromSeed(seed); - const eyeColor = deriveEyeColorFromSeed(seed); - const pattern = derivePatternFromSeed(seed); - const specialMark = deriveSpecialMarkFromSeed(seed); - const size = deriveSizeFromSeed(seed); - - return [ - ['d', d], - ['b', BLOBBI_ECOSYSTEM_NAMESPACE], - ['name', name], - ['stage', 'egg'], - ['state', 'active'], - ['seed', seed], - ['generation', '1'], - ['breeding_ready', 'false'], - ['experience', '0'], - ['care_streak', '1'], - ['care_streak_last_at', now], - ['care_streak_last_day', getLocalDayString()], - ['hunger', DEFAULT_EGG_STATS.hunger.toString()], - ['happiness', DEFAULT_EGG_STATS.happiness.toString()], - ['health', DEFAULT_EGG_STATS.health.toString()], - ['hygiene', DEFAULT_EGG_STATS.hygiene.toString()], - ['energy', DEFAULT_EGG_STATS.energy.toString()], - ['last_interaction', now], - ['last_decay_at', now], - // Visual traits (derived from seed, explicitly stored for consistency) - ['base_color', baseColor], - ['secondary_color', secondaryColor], - ['eye_color', eyeColor], - ['pattern', pattern], - ['special_mark', specialMark], - ['size', size], - ]; -} - -// ─── Managed Tag Sets (Separated by Kind) ───────────────────────────────────── - -/** - * Tags managed by the client for Kind 31124 (Blobbi State). - * These tags are controlled by the application and may be overwritten. - * - * @see blobbi-tag-schema.ts for the complete canonical schema documentation - */ -export const MANAGED_BLOBBI_STATE_TAG_NAMES = new Set([ - // System / metadata tags - 'd', 'b', - // Core identity tags - 'name', 'seed', 'generation', - // Lifecycle state tags - 'stage', 'state', 'last_interaction', 'last_decay_at', - // Stat tags - 'hunger', 'happiness', 'health', 'hygiene', 'energy', - // Visual trait tags (derived from seed, stored for fast rendering) - 'base_color', 'secondary_color', 'eye_color', 'pattern', 'special_mark', 'size', - // Identity/personality tags (MUST persist across stage transitions) - 'personality', 'trait', 'favorite_food', 'voice_type', 'mood', - // Progression tags - 'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day', - // Social/flag tags - 'breeding_ready', - // Task system tags (removed after stage transitions) - 'state_started_at', 'task', 'task_completed', - // Evolution tags (adult only) - 'adult_type', - // Extension tags (for themes/crossovers) - 'theme', 'crossover_app', -]); - -/** - * Visual trait tags that are part of the canonical Blobbi format. - * These tags ensure deterministic visual rendering across clients. - * - * Note: While seed is the ultimate source of truth for visual generation, - * these tags are explicitly stored for compatibility and faster rendering. - */ -export const VISUAL_TRAIT_TAG_NAMES = [ - 'base_color', - 'secondary_color', - 'eye_color', - 'pattern', - 'special_mark', - 'size', -] as const; - -/** - * Deprecated tags that should be removed when republishing events. - * These tags were part of earlier designs but are no longer used. - * - * - t: Topic tag (blobbi) - no longer needed, the app adds the client tag automatically - * - client: Client tag - no longer needed, the app adds this automatically via useNostrPublish - * - shell_integrity: Eggs now use the standard health stat instead - * - egg_temperature: Eggs now rely on warmth prop fallback; not part of active stat model - * - incubation_progress: Obsolete task progress field - * - egg_status: Obsolete status field - * - fees: Obsolete fee tracking field - * - incubation_time: Obsolete; task system uses state_started_at instead - * - start_incubation: Obsolete; replaced by state_started_at - * - interact_6_progress: Legacy interaction tracking; replaced by ["task", "interactions:N"] - */ -export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([ - 't', - 'client', - 'shell_integrity', - 'egg_temperature', - 'incubation_progress', - 'egg_status', - 'fees', - 'incubation_time', - 'start_incubation', - 'interact_6_progress', -]); - -/** - * Tags managed by the client for Kind 11125 (Blobbonaut Profile). - * These tags are controlled by the application and may be overwritten. - */ -export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([ - 'd', 'b', 'name', 'current_companion', 'onboarding_done', 'has', 'storage', - // Legacy player progress tags (preserved for compatibility) - 'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis', - 'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi', -]); - -/** - * Combined set for backwards compatibility. - * @deprecated Use kind-specific sets instead - */ -const MANAGED_TAG_NAMES = new Set([ - ...MANAGED_BLOBBI_STATE_TAG_NAMES, - ...MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES, -]); - -/** - * Merge tags for republishing, preserving unknown tags from the original event. - * @param existingTags - Tags from the original event - * @param newTags - New tags to apply (will override existing by tag name) - * @returns Merged tags array - */ -export function mergeTagsForRepublish( - existingTags: string[][], - newTags: string[][] -): string[][] { - // Create a map of new tags by their first element (tag name) - const newTagsMap = new Map(); - for (const tag of newTags) { - const name = tag[0]; - if (!newTagsMap.has(name)) { - newTagsMap.set(name, []); - } - newTagsMap.get(name)!.push(tag); - } - - // Start with existing unknown tags (tags we don't manage and that aren't deprecated) - const unknownTags = existingTags.filter(tag => - !MANAGED_TAG_NAMES.has(tag[0]) && !DEPRECATED_BLOBBI_TAG_NAMES.has(tag[0]) - ); - - // Collect all new tags in order - const result: string[][] = []; - - // Add new tags first - for (const tags of newTagsMap.values()) { - result.push(...tags); - } - - // Preserve unknown tags - result.push(...unknownTags); - - return result; -} - -/** - * Update specific tags in a Blobbi event while preserving unknown tags. - * Uses MANAGED_BLOBBI_STATE_TAG_NAMES for Kind 31124. - */ -export function updateBlobbiTags( - existingTags: string[][], - updates: Record -): string[][] { - return mergeBlobbiStateTagsForRepublish(existingTags, updates); -} - -/** - * Merge tags for republishing a Kind 31124 Blobbi State event. - * Preserves unknown tags, applies updates to managed tags, and validates the result. - * - * This function automatically: - * - Preserves existing managed tags that aren't being updated - * - Applies updates - * - Preserves unknown tags (for forward compatibility) - * - Filters out deprecated tags - * - Validates and repairs the final tag set - * - * @param existingTags - Current tags from the event - * @param updates - Tags to update (will override existing by tag name) - * @param options - Optional configuration - * @returns Validated and repaired tag array - */ -export function mergeBlobbiStateTagsForRepublish( - existingTags: string[][], - updates: Record, - options?: { - /** If true, skips validation (use with caution) */ - skipValidation?: boolean; - } -): string[][] { - const newTags: string[][] = []; - const updateKeys = new Set(Object.keys(updates)); - - // Preserve existing managed tags that aren't being updated - for (const tag of existingTags) { - const name = tag[0]; - if (MANAGED_BLOBBI_STATE_TAG_NAMES.has(name) && !updateKeys.has(name)) { - newTags.push(tag); - } - } - - // Add updates - for (const [name, value] of Object.entries(updates)) { - if (Array.isArray(value)) { - for (const v of value) { - newTags.push([name, v]); - } - } else { - newTags.push([name, value]); - } - } - - // Preserve unknown tags (tags not managed by us), excluding deprecated tags - const unknownTags = existingTags.filter(tag => - !MANAGED_BLOBBI_STATE_TAG_NAMES.has(tag[0]) && - !DEPRECATED_BLOBBI_TAG_NAMES.has(tag[0]) - ); - - const mergedTags = [...newTags, ...unknownTags]; - - // Skip validation if requested (for internal use) - if (options?.skipValidation) { - return mergedTags; - } - - // Validate and repair the final tag set - // Use existingTags as the recovery source for missing required tags - const result = validateAndRepairBlobbiTags(mergedTags, existingTags); - - // Log repairs in development - if (import.meta.env.DEV && result.repaired) { - console.log('[Blobbi] Tag repairs applied:', result.repairs); - } - - // Log errors (these are non-fatal but should be monitored) - if (result.errors.length > 0) { - console.warn('[Blobbi] Tag validation errors:', result.errors); - } - - return result.tags; -} - -/** - * Merge tags for republishing a Kind 11125 Blobbonaut Profile event. - * Preserves unknown tags, applies updates, and deduplicates repeated tags like 'has'. - */ -export function mergeBlobbonautTagsForRepublish( - existingTags: string[][], - updates: Record -): string[][] { - const newTags: string[][] = []; - const updateKeys = new Set(Object.keys(updates)); - - // Preserve existing managed tags that aren't being updated - for (const tag of existingTags) { - const name = tag[0]; - if (MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES.has(name) && !updateKeys.has(name)) { - newTags.push(tag); - } - } - - // Add updates - for (const [name, value] of Object.entries(updates)) { - if (Array.isArray(value)) { - for (const v of value) { - newTags.push([name, v]); - } - } else { - newTags.push([name, value]); - } - } - - // Preserve unknown tags (tags not managed by us) - const unknownTags = existingTags.filter(tag => !MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES.has(tag[0])); - - // Deduplicate 'has' tags - return deduplicateHasTags([...newTags, ...unknownTags]); -} - -/** - * Deduplicate 'has' tags in a tag array. - * Ensures each pet reference appears only once. - */ -export function deduplicateHasTags(tags: string[][]): string[][] { - const seenHas = new Set(); - const result: string[][] = []; - - for (const tag of tags) { - if (tag[0] === 'has') { - const value = tag[1]; - if (value && !seenHas.has(value)) { - seenHas.add(value); - result.push(tag); - } - } else { - result.push(tag); - } - } - - return result; -} - -/** - * Update Blobbonaut profile tags with proper deduplication. - * Use this when updating Kind 11125 events. - */ -export function updateBlobbonautTags( - existingTags: string[][], - updates: Record -): string[][] { - return mergeBlobbonautTagsForRepublish(existingTags, updates); -} - -// ─── Profile Normalization ──────────────────────────────────────────────────── - -/** - * Check if a Blobbonaut profile is missing the pettingLevel tag. - * This helps determine if normalization is needed. - */ -export function profileNeedsPettingLevelNormalization(profile: BlobbonautProfile): boolean { - // Check if either pettingLevel or petting_level tag exists in allTags - const hasPettingLevelTag = profile.allTags.some( - ([name]) => name === 'pettingLevel' || name === 'petting_level' - ); - return !hasPettingLevelTag; -} - -/** - * Build updated tags for normalizing a profile to include pettingLevel. - * Preserves all existing tags and adds pettingLevel: 0 if missing. - */ -export function buildNormalizedProfileTags(profile: BlobbonautProfile): string[][] { - if (!profileNeedsPettingLevelNormalization(profile)) { - return profile.allTags; - } - - return updateBlobbonautTags(profile.allTags, { - pettingLevel: '0', - }); -} - -// ─── Query Helpers ──────────────────────────────────────────────────────────── - -/** - * Get all possible d-tag values to query for a Blobbonaut profile. - * Includes canonical and legacy formats for migration support. - */ -export function getBlobbonautQueryDValues(pubkey: string): string[] { - const prefix12 = getPubkeyPrefix12(pubkey); - const prefix8 = pubkey.slice(0, 8).toLowerCase(); - - return [ - // Canonical - `blobbonaut-${prefix12}`, - // Legacy: capitalized - `Blobbonaut-${prefix12}`, - `Blobbonaut-${prefix8}`, - // Legacy: generic - 'blobbonaut-profile', - // Legacy: shorter prefixes - `blobbonaut-${prefix8}`, - ]; -} - -// ─── Legacy Migration Helpers ───────────────────────────────────────────────── - -/** - * Build tags for migrating a legacy Blobbi pet to canonical format. - * - * Migration preserves: - * - seed (existing or derived once) - * - name (tag > legacy d-tag derived > fallback) - * - core state tags (stage, state, stats, etc.) - * - legacy visual tags (explicitly preserved for backwards compatibility) - * - unknown tags (for forward compatibility) - * - * @param legacyEvent - The original legacy event - * @param newPetId - The new 10-char hex petId for canonical format - * @param pubkey - The owner's pubkey - * @returns Tags for the new canonical event - */ -export function buildMigrationTags( - legacyEvent: NostrEvent, - newPetId: string, - pubkey: string -): string[][] { - const canonicalD = getCanonicalBlobbiD(pubkey, newPetId); - const legacyTags = legacyEvent.tags; - - // Get or derive seed - use legacy event's created_at for consistency - // IMPORTANT: If seed exists and is valid, preserve it. Only derive if missing. - const existingSeed = getTagValue(legacyTags, 'seed'); - const seed = existingSeed && existingSeed.length === 64 - ? existingSeed - : deriveBlobbiSeedV1(pubkey, canonicalD, legacyEvent.created_at); - - const now = Math.floor(Date.now() / 1000).toString(); - - // Start with required tags - const newTags: string[][] = [ - ['d', canonicalD], - ['b', BLOBBI_ECOSYSTEM_NAMESPACE], - ['seed', seed], - ]; - - // Preserve name with priority: name tag > legacy d-tag derived > fallback - const nameTag = getTagValue(legacyTags, 'name'); - const legacyD = getTagValue(legacyTags, 'd'); - const resolvedName = nameTag ?? (legacyD ? deriveNameFromLegacyD(legacyD) : 'Unnamed Blobbi'); - newTags.push(['name', resolvedName]); - - // Preserve all persistent tags from the legacy event - // This includes: state, stats, progression, social, personality, evolution, and extension tags - // Per blobbi-tag-schema.md: Do NOT invent values for tags that don't exist - const persistentTagNames = [ - // State/lifecycle tags - 'stage', 'state', - // Stat tags - 'hunger', 'happiness', 'health', 'hygiene', 'energy', - // Progression tags - 'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day', - // Social/flag tags - 'generation', 'breeding_ready', - // Personality tags (preserve if they exist, do NOT generate) - 'personality', 'trait', 'favorite_food', 'voice_type', 'mood', - // Evolution tags - 'adult_type', - // Extension tags - 'theme', 'crossover_app', - ]; - - for (const tagName of persistentTagNames) { - const value = getTagValue(legacyTags, tagName); - if (value !== undefined) { - newTags.push([tagName, value]); - } - } - - // ALWAYS include visual trait tags - derived from seed, with legacy tag fallbacks - // This ensures every migrated event has complete visual traits for consistent rendering - const visualTraits = deriveVisualTraits(legacyTags, seed); - newTags.push(['base_color', visualTraits.baseColor]); - newTags.push(['secondary_color', visualTraits.secondaryColor]); - newTags.push(['eye_color', visualTraits.eyeColor]); - newTags.push(['pattern', visualTraits.pattern]); - newTags.push(['special_mark', visualTraits.specialMark]); - newTags.push(['size', visualTraits.size]); - - // Update timestamps - newTags.push(['last_interaction', now]); - const lastDecay = getTagValue(legacyTags, 'last_decay_at'); - if (lastDecay) { - newTags.push(['last_decay_at', lastDecay]); - } else { - newTags.push(['last_decay_at', now]); - } - - // Preserve truly unknown tags for forward compatibility - // (tags not in managed set AND not in visual trait set AND not deprecated) - const knownTagNames = new Set([ - ...MANAGED_BLOBBI_STATE_TAG_NAMES, - ...VISUAL_TRAIT_TAG_NAMES, - ...DEPRECATED_BLOBBI_TAG_NAMES, - ]); - const unknownTags = legacyTags.filter(tag => !knownTagNames.has(tag[0])); - - const assembledTags = [...newTags, ...unknownTags]; - - // ─── Validate and Repair Tags ─── - // Use the tag integrity guard to ensure all required tags are present - // and deprecated tags are removed - const repairResult = validateAndRepairBlobbiTags(assembledTags, legacyTags); - - if (import.meta.env.DEV) { - if (repairResult.repaired) { - console.log('[Migration] Tag repairs applied:', repairResult.repairs); - } - if (repairResult.errors.length > 0) { - console.warn('[Migration] Tag validation errors:', repairResult.errors); - } - } - - return repairResult.tags; -} - -/** - * Check if a Blobbi needs migration to canonical format. - */ -export function needsCanonicalMigration(d: string): boolean { - return isLegacyBlobbiD(d); -} - -/** - * Add a pet to the profile's 'has' list without duplicates. - * Returns updated has array. - */ -export function addPetToHas(currentHas: string[], newPetD: string): string[] { - if (currentHas.includes(newPetD)) { - return currentHas; - } - return [...currentHas, newPetD]; -} - -/** - * Remove a legacy pet ID from 'has' and replace with canonical. - */ -export function migratePetInHas( - currentHas: string[], - legacyD: string, - canonicalD: string -): string[] { - const filtered = currentHas.filter(d => d !== legacyD); - if (!filtered.includes(canonicalD)) { - filtered.push(canonicalD); - } - return filtered; -} - -// ─── LocalStorage Cache Types ───────────────────────────────────────────────── - -export interface BlobbiBootCache { - /** The user's pubkey this cache belongs to */ - pubkey: string; - profile: BlobbonautProfile | null; - companion: BlobbiCompanion | null; - cachedAt: number; -} - -export const BLOBBI_CACHE_KEY = 'blobbi:boot-cache'; diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index 517c11c4..a472cb4b 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; -import { Egg, Moon, Sun, Loader2, RefreshCw, Check, Target, Package, Sparkles, HeartHandshake, Plus, Camera, ArrowLeft, AlertTriangle, X, Footprints, Wrench, Theater, MoreHorizontal, ExternalLink } from 'lucide-react'; +import { Egg, Moon, Sun, RefreshCw, Check, Target, Package, Sparkles, HeartHandshake, Plus, Camera, AlertTriangle, X, Footprints, Wrench, Theater, MoreHorizontal, ExternalLink, Settings2 } from 'lucide-react'; // Note: Sparkles kept for BlobbiBottomBar center action button // Note: Plus kept for AdoptAnotherBlobbiCard // Note: AlertTriangle kept for stat warning indicators @@ -80,6 +80,7 @@ import { type SelectedTrack, type BlobbiReactionState, type StartIncubationMode, + useDailyMissions, } from '@/blobbi/actions'; import { BlobbiOnboardingFlow } from '@/blobbi/onboarding'; import { useBlobbiActionsRegistration, type UseItemFunction } from '@/blobbi/companion/interaction'; @@ -88,6 +89,23 @@ import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction'; import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe'; import { getActionEmotion, type ActionType } from '@/blobbi/ui/lib/status-reactions'; import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions'; +import { MissionSurfaceCard } from '@/blobbi/ui/components/MissionSurfaceCard'; +import { ActionBarEditor } from '@/blobbi/ui/components/ActionBarEditor'; +import { + type ActionBarPreferences, + type BarItemId, + getVisibleSlots, + getVisibleBarIds, + loadPreferences, + savePreferences, + loadMissionCardVisible, + saveMissionCardVisible, +} from '@/blobbi/ui/lib/action-bar-preferences'; +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import { useFirstHatchTour, useFirstHatchTourActivation, FirstHatchTourCard } from '@/blobbi/tour'; +import { buildHatchPhrase, isValidHatchPost } from '@/blobbi/actions'; +import type { EggTourVisualState } from '@/blobbi/egg'; /** * Get the localStorage key for the selected Blobbi. @@ -871,6 +889,24 @@ function BlobbiDashboard({ const [showMissionsModal, setShowMissionsModal] = useState(false); const [showShopModal, setShowShopModal] = useState(false); const [showPhotoModal, setShowPhotoModal] = useState(false); + const [showBarEditor, setShowBarEditor] = useState(false); + + // ─── Action Bar Preferences ─── + const [barPrefs, setBarPrefs] = useState(loadPreferences); + const handleBarPrefsUpdate = useCallback((prefs: ActionBarPreferences) => { + setBarPrefs(prefs); + savePreferences(prefs); + }, []); + + // ─── Mission Surface Card Visibility ─── + const [missionCardVisible, setMissionCardVisible] = useState(loadMissionCardVisible); + const handleToggleMissionCard = useCallback((visible: boolean) => { + setMissionCardVisible(visible); + saveMissionCardVisible(visible); + }, []); + + // ─── Daily Missions (for surface card) ─── + const dailyMissions = useDailyMissions({ availableStages }); // DEV ONLY: Emotion panel state const [showEmotionPanel, setShowEmotionPanel] = useState(false); @@ -934,6 +970,168 @@ function BlobbiDashboard({ const [showIncubationDialog, setShowIncubationDialog] = useState(false); const [showEvolutionDialog, setShowEvolutionDialog] = useState(false); + // ─── First Hatch Tour ─── + const firstHatchTour = useFirstHatchTour(); + useFirstHatchTourActivation({ + companions, + isLoading: false, // companions are already loaded at this point + tour: firstHatchTour, + profileOnboardingDone: profile?.onboardingDone, + }); + const isFirstHatchTourActive = firstHatchTour.state.isActive; + + // The required phrase for the first-hatch post + const firstHatchPhrase = useMemo(() => buildHatchPhrase(companion.name), [companion.name]); + + // Auto-advance from idle -> show_hatch_card (immediately) + useEffect(() => { + if (!isFirstHatchTourActive) return; + if (firstHatchTour.isStep('idle')) { + firstHatchTour.actions.advance(); // -> show_hatch_card + } + }, [isFirstHatchTourActive, firstHatchTour]); + + // Show the inline first-hatch card for all pre-hatch steps + const showFirstHatchCard = isFirstHatchTourActive && firstHatchTour.isAnyStep( + 'show_hatch_card', 'egg_glowing_waiting_click', + 'egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3', + ); + + // Detect hatch post completion for the first-hatch tour + const { user } = useCurrentUser(); + const { nostr } = useNostr(); + const tourAwaitingPost = isFirstHatchTourActive && firstHatchTour.isStep('show_hatch_card'); + + const { data: tourPostFound } = useQuery({ + queryKey: ['first-hatch-tour-post', user?.pubkey, companion.name], + queryFn: async () => { + if (!user?.pubkey) return false; + const events = await nostr.query([{ + kinds: [1], + authors: [user.pubkey], + limit: 20, + }]); + return events.some(e => isValidHatchPost(e, companion.name)); + }, + enabled: tourAwaitingPost && !!user?.pubkey, + refetchInterval: 5000, + staleTime: 3000, + }); + + // When the post is found during show_hatch_card, show the completed state + // for 2 seconds so the user sees the checkmark, then auto-advance to glowing. + useEffect(() => { + if (!tourPostFound || !isFirstHatchTourActive) return; + if (firstHatchTour.isStep('show_hatch_card')) { + const timer = setTimeout(() => { + firstHatchTour.actions.goTo('egg_glowing_waiting_click'); + }, 2000); + return () => clearTimeout(timer); + } + }, [tourPostFound, isFirstHatchTourActive, firstHatchTour]); + + // Fake pointer hint: after 10s on glowing_waiting_click, show hint; repeat every 5s + const [showClickHint, setShowClickHint] = useState(false); + useEffect(() => { + if (!isFirstHatchTourActive || !firstHatchTour.isStep('egg_glowing_waiting_click')) { + setShowClickHint(false); + return; + } + const initial = setTimeout(() => setShowClickHint(true), 10000); + const repeat = setInterval(() => setShowClickHint(true), 5000); + return () => { clearTimeout(initial); clearInterval(repeat); }; + }, [isFirstHatchTourActive, firstHatchTour]); + + // Handle egg click during the tour (advance crack stages) + const handleTourEggClick = useCallback(() => { + if (!isFirstHatchTourActive) return; + setShowClickHint(false); + if (firstHatchTour.isStep('egg_glowing_waiting_click')) { + firstHatchTour.actions.advance(); // -> egg_crack_stage_1 + } else if (firstHatchTour.isStep('egg_crack_stage_1')) { + firstHatchTour.actions.advance(); // -> egg_crack_stage_2 + } else if (firstHatchTour.isStep('egg_crack_stage_2')) { + firstHatchTour.actions.advance(); // -> egg_crack_stage_3 + } else if (firstHatchTour.isStep('egg_crack_stage_3')) { + firstHatchTour.actions.advance(); // -> egg_opening + } + }, [isFirstHatchTourActive, firstHatchTour]); + + // Auto-advance for opening -> hatching -> complete (with hatch mutation) + useEffect(() => { + if (!isFirstHatchTourActive) return; + if (firstHatchTour.isStep('egg_opening')) { + const timer = setTimeout(() => { + firstHatchTour.actions.advance(); // -> egg_hatching + }, 1500); + return () => clearTimeout(timer); + } + }, [isFirstHatchTourActive, firstHatchTour]); + + useEffect(() => { + if (!isFirstHatchTourActive) return; + if (firstHatchTour.isStep('egg_hatching')) { + // Execute the actual hatch mutation, mark onboarding complete on the + // profile event, then complete the tour's local state. + const doHatch = async () => { + try { + await onHatch(); + + // Persist blobbi_onboarding_done to the Blobbonaut profile (authoritative) + if (profile) { + try { + const updatedTags = updateBlobbonautTags(profile.allTags, { + blobbi_onboarding_done: 'true', + }); + const event = await publishEvent({ + kind: KIND_BLOBBONAUT_PROFILE, + content: '', + tags: updatedTags, + }); + updateProfileEvent(event); + } catch (e) { + console.error('[FirstHatchTour] Failed to persist onboarding completion to profile:', e); + } + } + } finally { + firstHatchTour.actions.complete(); + } + }; + const timer = setTimeout(doHatch, 1200); + return () => clearTimeout(timer); + } + }, [isFirstHatchTourActive, firstHatchTour, onHatch, profile, publishEvent, updateProfileEvent]); + + // Derive tourVisualState for the egg visual + const tourVisualState = useMemo((): EggTourVisualState => { + if (!isFirstHatchTourActive) return 'idle'; + const step = firstHatchTour.state.currentStepId; + switch (step) { + case 'show_hatch_card': return 'show_hatch_card'; + case 'egg_glowing_waiting_click': return 'glowing_waiting_click'; + case 'egg_crack_stage_1': return 'crack_stage_1'; + case 'egg_crack_stage_2': return 'crack_stage_2'; + case 'egg_crack_stage_3': return 'crack_stage_3'; + case 'egg_opening': return 'opening'; + case 'egg_hatching': return 'hatching'; + default: return 'idle'; + } + }, [isFirstHatchTourActive, firstHatchTour.state.currentStepId]); + + // DEV ONLY: Build tour dev actions for the state editor + const tourDevActions = useMemo(() => ({ + skipPostRequirement: () => { + if (firstHatchTour.isStep('show_hatch_card')) { + firstHatchTour.actions.goTo('egg_glowing_waiting_click'); + } + }, + resetTour: () => { + firstHatchTour.actions.reset(); + }, + currentStepId: firstHatchTour.state.currentStepId, + isCompleted: firstHatchTour.state.isCompleted, + }), [firstHatchTour]); + // State detection for tasks // Note: isEvolving prop = mutation pending state, isEvolvingState = companion in evolving state const isIncubating = companion.state === 'incubating'; @@ -1348,8 +1546,6 @@ function BlobbiDashboard({ {/* Hero Section */}
- {/* Floating Dashboard Controls */} - {/* Blobbi Name */}
@@ -1363,7 +1559,6 @@ function BlobbiDashboard({ {/* Main Blobbi Visual */} {isActiveFloatingCompanion ? ( - // Show message when Blobbi is active as floating companion

@@ -1372,7 +1567,6 @@ function BlobbiDashboard({

) : (
- {/* Subtle glow effect behind the egg */}
+ {showClickHint && firstHatchTour.isStep('egg_glowing_waiting_click') && ( +
+ 👆 +
+ )}
)} -
- {/* Stats Section */} + {/* First Hatch Tour: inline card directly below egg (above stats) */} + {showFirstHatchCard && ( +
+ setShowPostModal(true)} + currentStep={firstHatchTour.state.currentStepId} + /> +
+ )} + + {/* Stats Section - hidden during first-hatch tour */} + {!isFirstHatchTourActive && (
{/* Stats Grid - shows projected decay state */} {/* Only stats below the visibility threshold are shown (centralized in getVisibleStatsWithValues) */} @@ -1421,9 +1634,7 @@ function BlobbiDashboard({ ); })()} - - - {/* Inline Activity Area - inside padded container for proper spacing above bottom bar */} + {/* Inline Activity Area */} {inlineActivity.type === 'music' && (
)}
+ )} - {/* Bottom Action Bar */} - setShowSelector(true)} - onMissionsClick={() => setShowMissionsModal(true)} - onActionsClick={() => setShowActionsModal(true)} - onShopClick={() => setShowShopModal(true)} - needyBlobbiesCount={companions.filter(companionNeedsCare).length} - isInTaskProcess={isInTaskProcess} - remainingTasksCount={remainingTasksCount} - allTasksComplete={allTasksComplete} - stage={companion.stage} - blobbiNaddr={blobbiNaddr} - onSetAsCompanion={handleSetAsCompanion} - isCurrentCompanion={isCurrentCompanion} - isUpdatingCompanion={isUpdatingCompanion} - onTakePhoto={() => setShowPhotoModal(true)} - onEvolve={ - canStartIncubation - ? () => setShowIncubationDialog(true) - : canStartEvolution - ? () => setShowEvolutionDialog(true) - : isEgg - ? onHatch - : onEvolve - } - isTransitioning={isHatching || isEvolving || isStartingIncubation || isStartingEvolution} - hideEvolveButton={isIncubating || isEvolvingState} - isIncubationAction={canStartIncubation} - isEvolutionAction={canStartEvolution} - onDevInstantTransition={isEgg ? onHatch : isBaby ? onEvolve : undefined} - onDevOpenEditor={() => setShowDevEditor(true)} - onDevOpenEmotionPanel={() => setShowEmotionPanel(true)} + {/* Mission Surface Card - hidden during first-hatch tour or when user dismissed */} + {!isFirstHatchTourActive && missionCardVisible && ( +
+ setShowMissionsModal(true)} + onHide={() => handleToggleMissionCard(false)} + /> +
+ )} + + {/* Bottom Action Bar - hidden during first-hatch tour */} + {!isFirstHatchTourActive && ( + setShowSelector(true)} + onMissionsClick={() => setShowMissionsModal(true)} + onActionsClick={() => setShowActionsModal(true)} + onShopClick={() => setShowShopModal(true)} + needyBlobbiesCount={companions.filter(companionNeedsCare).length} + isInTaskProcess={isInTaskProcess} + remainingTasksCount={remainingTasksCount} + allTasksComplete={allTasksComplete} + stage={companion.stage} + blobbiNaddr={blobbiNaddr} + onSetAsCompanion={handleSetAsCompanion} + isCurrentCompanion={isCurrentCompanion} + isUpdatingCompanion={isUpdatingCompanion} + onTakePhoto={() => setShowPhotoModal(true)} + onEvolve={ + canStartIncubation + ? () => setShowIncubationDialog(true) + : canStartEvolution + ? () => setShowEvolutionDialog(true) + : isEgg + ? onHatch + : onEvolve + } + isTransitioning={isHatching || isEvolving || isStartingIncubation || isStartingEvolution} + hideEvolveButton={isIncubating || isEvolvingState || isFirstHatchTourActive} + isIncubationAction={canStartIncubation} + isEvolutionAction={canStartEvolution} + onDevInstantTransition={isEgg ? onHatch : isBaby ? onEvolve : undefined} + onDevOpenEditor={() => setShowDevEditor(true)} + onDevOpenEmotionPanel={() => setShowEmotionPanel(true)} + barPreferences={barPrefs} + onEditBar={() => setShowBarEditor(true)} + /> + )} + + {/* Action Bar Editor */} + {/* Blobbi Selector Modal */} @@ -1596,6 +1834,8 @@ function BlobbiDashboard({ onStopEvolution={handleStopEvolution} isStoppingEvolution={isStoppingEvolution} availableStages={availableStages} + showMissionCard={missionCardVisible} + onToggleMissionCard={handleToggleMissionCard} /> {/* Shop & Inventory Modal (unified) */} @@ -1617,6 +1857,7 @@ function BlobbiDashboard({ onSuccess={refetchCurrentTasks} /> + {/* Blobbi Photo Modal - polaroid-style photo capture */} )} @@ -1665,39 +1907,6 @@ function BlobbiDashboard({ ); } -// ─── Quick Action Button ────────────────────────────────────────────────────── - -interface QuickActionButtonProps { - children: React.ReactNode; - tooltip: string; - onClick?: () => void; - disabled?: boolean; - loading?: boolean; -} - -function QuickActionButton({ children, tooltip, onClick, disabled, loading }: QuickActionButtonProps) { - return ( - - - - - -

{tooltip}

-
-
- ); -} - -// ─── Dashboard Floating Controls ────────────────────────────────────────────── - /** * Get the appropriate tooltip for the evolve/hatch button based on stage and action state. */ @@ -1715,18 +1924,6 @@ function getEvolveTooltip( return 'Evolve'; } -/** Floating back button for the Blobbi dashboard. */ -function BlobbiDashboardFloatingControls({ onBack }: { onBack?: () => void }) { - if (!onBack) return null; - return ( -
- - - -
- ); -} - // ─── Stat Indicator ─────────────────────────────────────────────────────────── interface StatIndicatorProps { @@ -2065,12 +2262,18 @@ interface BlobbiBottomBarProps { hideEvolveButton?: boolean; isIncubationAction?: boolean; isEvolutionAction?: boolean; + // ── Action bar preferences ── + barPreferences: ActionBarPreferences; + onEditBar: () => void; // ── Dev-only actions ── onDevInstantTransition?: () => void; onDevOpenEditor?: () => void; onDevOpenEmotionPanel?: () => void; } +/** Handler map keyed by BarItemId so the bar can generically call the right action */ +type BarItemHandlers = Record void>; + function BlobbiBottomBar({ onBlobbiesClick, onMissionsClick, @@ -2092,21 +2295,74 @@ function BlobbiBottomBar({ hideEvolveButton = false, isIncubationAction = false, isEvolutionAction = false, + // Bar preferences + barPreferences, + onEditBar, // Dev-only props onDevInstantTransition, onDevOpenEditor, onDevOpenEmotionPanel, }: BlobbiBottomBarProps) { // Determine what to show on missions badge: - // - If all tasks complete during active process: show "!" - // - If tasks remaining during active process: show count - // - Otherwise: no badge - // Works for BOTH incubating (hatch) and evolving processes const missionsBadge = allTasksComplete ? '!' : (isInTaskProcess && remainingTasksCount && remainingTasksCount > 0 ? remainingTasksCount : undefined); const canBeCompanion = stage !== 'egg'; const showEvolveButton = stage !== 'adult' && !hideEvolveButton; + // Handler map for customizable items + const handlers: BarItemHandlers = useMemo(() => ({ + blobbies: onBlobbiesClick, + missions: onMissionsClick, + items: onShopClick, + take_photo: onTakePhoto, + set_companion: onSetAsCompanion, + }), [onBlobbiesClick, onMissionsClick, onShopClick, onTakePhoto, onSetAsCompanion]); + + // Icon map for customizable items + const iconMap: Record = useMemo(() => ({ + blobbies: , + missions: , + items: , + take_photo: , + set_companion: , + }), [isCurrentCompanion]); + + // Label map + const labelMap: Record = { + blobbies: 'Blobbies', + missions: 'Missions', + items: 'Items', + take_photo: 'Photo', + set_companion: isCurrentCompanion ? 'Companion' : 'Companion', + }; + + // Badge map + const badgeMap: Record = useMemo(() => ({ + blobbies: { + badge: needyBlobbiesCount && needyBlobbiesCount > 0 ? needyBlobbiesCount : undefined, + variant: needyBlobbiesCount && needyBlobbiesCount > 0 ? 'warning' as const : 'default' as const, + }, + missions: { + badge: missionsBadge, + variant: allTasksComplete ? 'success' as const : 'default' as const, + }, + items: {}, + take_photo: {}, + set_companion: {}, + }), [needyBlobbiesCount, missionsBadge, allTasksComplete]); + + // Visible custom slots from preferences + const visibleSlots = getVisibleSlots(barPreferences); + + // Set of item IDs currently visible in the bar (used to skip duplicates in More) + const visibleBarIds = useMemo(() => getVisibleBarIds(barPreferences), [barPreferences]); + + // Split into left group (before center) and right group (after center) + // Distribute: first half to left, rest to right + const halfIdx = Math.ceil(visibleSlots.length / 2); + const leftSlots = visibleSlots.slice(0, halfIdx); + const rightSlots = visibleSlots.slice(halfIdx); + return (
@@ -2114,23 +2370,20 @@ function BlobbiBottomBar({
{/* Left Group - aligned to end (closer to center) */}
- } - label="Blobbies" - badge={needyBlobbiesCount && needyBlobbiesCount > 0 ? needyBlobbiesCount : undefined} - badgeVariant={needyBlobbiesCount && needyBlobbiesCount > 0 ? 'warning' : 'default'} - /> - } - label="Missions" - badge={missionsBadge} - badgeVariant={allTasksComplete ? 'success' : 'default'} - /> + {leftSlots.map((slot) => ( + + ))}
- {/* Center Action Button */} + {/* Center Action Button (fixed) */}