Compare commits

...

1 Commits

Author SHA1 Message Date
filemon 56866c7de9 Update Blobbi XP 2026-03-29 04:25:14 -03:00
59 changed files with 4837 additions and 85 deletions
@@ -15,7 +15,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { cn } from '@/lib/utils';
import {
@@ -11,7 +11,7 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { InventoryAction, DirectAction } from '../lib/blobbi-action-utils';
interface BlobbiActionsModalProps {
@@ -27,7 +27,7 @@ import {
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useState } from 'react';
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { NostrEvent } from '@nostrify/nostrify';
import type { HatchTasksResult } from '../hooks/useHatchTasks';
import type { EvolveTasksResult } from '../hooks/useEvolveTasks';
@@ -22,7 +22,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -26,7 +26,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { StartIncubationMode } from '../hooks/useBlobbiIncubation';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -17,7 +17,7 @@
*/
import { useMemo } from 'react';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { HatchTask, HatchTasksResult } from './useHatchTasks';
import type { EvolveTasksResult } from './useEvolveTasks';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
@@ -23,8 +23,8 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import type { BlobbiCompanion } from '@/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/blobbi/core/lib/blobbi';
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
@@ -6,12 +6,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import {
clampStat,
applyStat,
@@ -22,6 +22,7 @@ import {
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
@@ -50,6 +51,8 @@ export interface DirectActionRequest {
export interface DirectActionResult {
action: DirectAction;
happinessChange: number;
xpGained: number;
newXP: number;
}
/**
@@ -129,6 +132,9 @@ export function useBlobbiDirectAction({
const happinessDelta = DIRECT_ACTION_HAPPINESS_EFFECTS[action];
const newHappiness = applyStat(statsAfterDecay.happiness, happinessDelta);
// Track if happiness actually changed
const happinessChanged = newHappiness !== statsAfterDecay.happiness;
// Build stats update
const isEgg = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {
@@ -161,9 +167,16 @@ export function useBlobbiDirectAction({
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain (ONLY if happiness actually changed) ───
// Direct actions modify happiness. Only grant XP if happiness actually increased.
const xpGained = happinessChanged ? calculateActionXP(action) : 0;
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
const blobbiTags = updateBlobbiTags(updatedTags, {
...statsUpdate,
...streakUpdates,
experience: newXP.toString(),
last_interaction: nowStr,
last_decay_at: nowStr,
});
@@ -185,13 +198,16 @@ export function useBlobbiDirectAction({
return {
action,
happinessChange: happinessDelta,
xpGained,
newXP,
};
},
onSuccess: ({ action, happinessChange }) => {
onSuccess: ({ action, happinessChange, xpGained }) => {
const actionMeta = DIRECT_ACTION_METADATA[action];
const xpText = formatXPGain(xpGained);
toast({
title: `${actionMeta.label} complete!`,
description: `Your Blobbi's happiness increased by ${happinessChange}!`,
description: `Your Blobbi's happiness increased by ${happinessChange}! ${xpText}`,
});
// Track daily mission progress
@@ -21,12 +21,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -62,7 +62,7 @@ export interface UseStartIncubationParams {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
@@ -325,7 +325,7 @@ export interface UseStopIncubationParams {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
@@ -476,7 +476,7 @@ export interface UseStartEvolutionParams {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
@@ -627,7 +627,7 @@ export interface UseStopEvolutionParams {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
@@ -780,7 +780,7 @@ export interface UseSyncTaskCompletionsParams {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
@@ -19,14 +19,14 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
DEFAULT_EGG_STATS,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
import { validateAndRepairBlobbiTags } from '@/lib/blobbi-tag-schema';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { validateAndRepairBlobbiTags } from '@/blobbi/core/lib/blobbi-tag-schema';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
// ─── Content Helpers ──────────────────────────────────────────────────────────
@@ -56,7 +56,7 @@ export interface CanonicalActionResult {
/** Latest profile tags after migration */
profileAllTags: string[][];
/** Latest profile storage after migration */
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
}
/**
@@ -6,15 +6,15 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
@@ -32,6 +32,7 @@ import {
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateInventoryActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
@@ -52,7 +53,10 @@ export interface UseItemResult {
itemName: string;
action: InventoryAction;
quantity: number;
effectiveItemCount: number; // How many items actually changed stats (may be less than quantity due to caps)
statsChanged: Record<string, number>;
xpGained: number;
newXP: number;
}
/**
@@ -70,7 +74,7 @@ export interface UseBlobbiUseInventoryItemParams {
/** Latest profile tags after migration (use instead of profile.allTags) */
profileAllTags: string[][];
/** Latest profile storage after migration (use instead of profile.storage) */
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
@@ -186,14 +190,49 @@ export function useBlobbiUseInventoryItem({
// Start with decayed stats as the base
const statsAfterDecay = decayResult.stats;
// ─── Validate Play Energy Requirements ───
// For play actions, validate the Blobbi has enough energy AFTER decay
if (action === 'play') {
const energyCost = Math.abs(shopItem.effect.energy ?? 0);
const currentEnergy = statsAfterDecay.energy;
if (energyCost > 0 && currentEnergy < energyCost) {
throw new Error(
`Your Blobbi needs at least ${energyCost} energy to play with this toy (current: ${currentEnergy})`
);
}
// Also check if playing would have any effect at all
// If happiness is maxed AND we can't spend energy, playing is pointless
const happinessGain = shopItem.effect.happiness ?? 0;
const currentHappiness = statsAfterDecay.happiness;
const wouldGainHappiness = happinessGain > 0 && currentHappiness < 100;
const wouldSpendEnergy = energyCost > 0 && currentEnergy >= energyCost;
if (!wouldGainHappiness && !wouldSpendEnergy) {
throw new Error(
'Playing would have no effect - your Blobbi is already at maximum happiness and has no energy to spend'
);
}
}
// ─── Apply Item Effects ───
// Apply effects multiple times (once per quantity) to simulate using items in sequence.
// This ensures proper clamping at each step, e.g., using 5 health items when at 90 health
// won't give more than 100 health total.
//
// CRITICAL: Track the number of items that actually produced INTENDED stat changes for XP.
// XP counting is action-aware - only count positive intended effects, NOT negative side effects:
// - feed: count when hunger/energy/health/happiness INCREASE (NOT when hygiene decreases)
// - clean: count when hygiene or happiness INCREASES
// - medicine: count when health/energy/happiness INCREASE (NOT negative side effects)
// - play: EXCEPTION - count when happiness increases OR energy decreases (both are intended effects)
//
// Use canonical companion stage for egg checks
const isEggCompanion = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
let effectiveItemCount = 0; // Number of items that produced intended effects
if (isEggCompanion && action === 'medicine') {
// Egg medicine handling:
@@ -203,9 +242,15 @@ export function useBlobbiUseInventoryItem({
const healthDelta = shopItem.effect.health ?? 0;
// Apply health effect N times in sequence with clamping at each step
// Only count items that actually INCREASED health (positive effect only)
let currentHealth = statsAfterDecay.health ?? 0;
for (let i = 0; i < quantity; i++) {
const prevHealth = currentHealth;
currentHealth = applyStat(currentHealth, healthDelta);
// Only count as effective if health increased (not just changed)
if (healthDelta > 0 && currentHealth > prevHealth) {
effectiveItemCount++;
}
}
statsUpdate.health = currentHealth.toString();
@@ -228,11 +273,20 @@ export function useBlobbiUseInventoryItem({
const happinessDelta = shopItem.effect.happiness ?? 0;
// Apply effects N times in sequence
// Only count items that INCREASED hygiene or happiness (positive effects only)
let currentHygiene = statsAfterDecay.hygiene ?? 0;
let currentHappiness = statsAfterDecay.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
const prevHygiene = currentHygiene;
const prevHappiness = currentHappiness;
currentHygiene = applyStat(currentHygiene, hygieneDelta);
currentHappiness = applyStat(currentHappiness, happinessDelta);
// Count as effective if hygiene OR happiness increased (positive effects only)
const hygieneIncreased = hygieneDelta > 0 && currentHygiene > prevHygiene;
const happinessIncreased = happinessDelta > 0 && currentHappiness > prevHappiness;
if (hygieneIncreased || happinessIncreased) {
effectiveItemCount++;
}
}
statsUpdate.hygiene = currentHygiene.toString();
@@ -252,9 +306,49 @@ export function useBlobbiUseInventoryItem({
} else {
// Normal stats application for baby/adult
// Apply item effects N times in sequence ON TOP of decayed stats
// Use action-aware effectiveness checking for XP calculation
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
const effect = shopItem.effect;
for (let i = 0; i < quantity; i++) {
currentStats = applyItemEffects(currentStats, shopItem.effect);
const prevStats = { ...currentStats };
currentStats = applyItemEffects(currentStats, effect);
// Action-aware effectiveness check:
// Only count INTENDED positive effects, not negative side effects
let isEffective = false;
if (action === 'feed') {
// Feed: count when hunger/energy/health/happiness INCREASE
// Do NOT count hygiene decrease (that's a side effect)
const hungerIncreased = (effect.hunger ?? 0) > 0 && (currentStats.hunger ?? 0) > (prevStats.hunger ?? 0);
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = hungerIncreased || energyIncreased || healthIncreased || happinessIncreased;
} else if (action === 'clean') {
// Clean: count when hygiene or happiness INCREASES
const hygieneIncreased = (effect.hygiene ?? 0) > 0 && (currentStats.hygiene ?? 0) > (prevStats.hygiene ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = hygieneIncreased || happinessIncreased;
} else if (action === 'medicine') {
// Medicine: count when health/energy/happiness INCREASE
// Do NOT count negative side effects (like happiness decrease on Super Medicine)
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = healthIncreased || energyIncreased || happinessIncreased;
} else if (action === 'play') {
// Play: EXCEPTION - both happiness increase AND energy decrease are intended effects
// Playing naturally consumes energy, so energy decrease counts as valid
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
const energyDecreased = (effect.energy ?? 0) < 0 && (currentStats.energy ?? 0) < (prevStats.energy ?? 0);
isEffective = happinessIncreased || energyDecreased;
}
if (isEffective) {
effectiveItemCount++;
}
}
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
@@ -288,9 +382,18 @@ export function useBlobbiUseInventoryItem({
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain (Based on effective item count) ───
// Only grant XP for items that actually changed stats.
// If user used 100 food items but hunger capped at item #4, only 4 items were effective.
// This prevents XP farming by mass-using items after stats are already maxed.
const xpGained = effectiveItemCount > 0 ? calculateInventoryActionXP(action, effectiveItemCount) : 0;
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
const blobbiTags = updateBlobbiTags(updatedTags, {
...statsUpdate,
...streakUpdates,
experience: newXP.toString(),
last_interaction: nowStr,
last_decay_at: nowStr,
});
@@ -330,15 +433,19 @@ export function useBlobbiUseInventoryItem({
itemName: shopItem.name,
action,
quantity,
effectiveItemCount, // How many items actually changed stats
statsChanged,
xpGained,
newXP,
};
},
onSuccess: ({ itemName, action, quantity }) => {
onSuccess: ({ itemName, action, quantity, xpGained }) => {
const actionMeta = ACTION_METADATA[action];
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
const xpText = formatXPGain(xpGained);
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${itemName}${quantityText} on your Blobbi.`,
description: `Used ${itemName}${quantityText} on your Blobbi. ${xpText}`,
});
// Track daily mission progress
@@ -14,11 +14,11 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbonautTags,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import {
type DailyMissionsState,
getTodayDateString,
+1 -1
View File
@@ -15,7 +15,7 @@ import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_THEME_DEFINITION,
+1 -1
View File
@@ -16,7 +16,7 @@ import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Constants ────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -111,7 +111,7 @@ export {
} from './lib/blobbi-activity-state';
// Re-export stat bounds from canonical source
export { STAT_MIN, STAT_MAX } from '@/lib/blobbi';
export { STAT_MIN, STAT_MAX } from '@/blobbi/core/lib/blobbi';
// Utilities
export {
@@ -1,6 +1,6 @@
// src/blobbi/actions/lib/blobbi-action-utils.ts
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/lib/blobbi';
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
+1 -1
View File
@@ -21,7 +21,7 @@ import {
getLocalDayString,
getDaysDifference,
type BlobbiCompanion,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
+138
View File
@@ -0,0 +1,138 @@
/**
* Blobbi XP (Experience Points) System
*
* This module defines XP values for all Blobbi care actions and provides
* utilities for calculating and applying XP gains.
*
* Design Philosophy:
* - Different actions award different XP to reflect their complexity/value
* - XP values are balanced to encourage variety in care activities
* - Direct actions (sing, play_music) give moderate XP as they're free
* - Inventory actions (feed, play, clean, medicine) give varied XP based on resource cost
* - XP accumulates across all life stages and never resets
*/
import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-action-utils';
// ─── XP Values by Action ──────────────────────────────────────────────────────
/**
* Base XP values for inventory actions (feed, play, clean, medicine).
* These actions consume items from the player's storage.
*/
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
feed: 5, // Feeding is common and essential - moderate XP
play: 8, // Playing toys provides good interaction - higher XP
clean: 6, // Hygiene maintenance is important - moderate-high XP
medicine: 10, // Medicine is costly and critical - highest inventory XP
};
/**
* Base XP values for direct actions (play_music, sing).
* These actions don't consume items - they're free activities.
*/
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
play_music: 7, // Playing music is engaging - good XP
sing: 9, // Singing requires more user effort - higher XP
};
/**
* Combined XP lookup for all action types.
* Use this for a unified XP calculation interface.
*/
export const ACTION_XP: Record<BlobbiAction, number> = {
...INVENTORY_ACTION_XP,
...DIRECT_ACTION_XP,
};
// ─── XP Calculation Utilities ─────────────────────────────────────────────────
/**
* Calculate XP gain for a single action.
*
* @param action - The action performed
* @returns XP points earned
*/
export function calculateActionXP(action: BlobbiAction): number {
return ACTION_XP[action] ?? 0;
}
/**
* Calculate total XP gain for using multiple items.
* Each item use counts as a separate action for XP purposes.
*
* @param action - The action performed
* @param quantity - Number of items used (defaults to 1)
* @returns Total XP points earned
*/
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
if (quantity < 1) return 0;
const baseXP = INVENTORY_ACTION_XP[action] ?? 0;
return baseXP * quantity;
}
/**
* Apply XP gain to current experience value.
*
* @param currentXP - Current experience points (undefined = 0)
* @param xpGain - XP points to add
* @returns New total XP (never negative)
*/
export function applyXPGain(currentXP: number | undefined, xpGain: number): number {
const current = currentXP ?? 0;
const newXP = current + xpGain;
return Math.max(0, newXP);
}
/**
* Get XP gain summary for displaying to the user.
*
* @param action - The action performed
* @param quantity - Number of times the action was performed (for inventory actions)
* @returns Object with xpGained and total quantity
*/
export function getXPGainSummary(
action: BlobbiAction,
quantity: number = 1
): { xpGained: number; quantity: number } {
const baseXP = ACTION_XP[action] ?? 0;
const xpGained = baseXP * quantity;
return { xpGained, quantity };
}
// ─── XP Display Utilities ─────────────────────────────────────────────────────
/**
* Format XP gain for display in toasts/notifications.
*
* @param xpGained - Amount of XP gained
* @returns Formatted string like "+15 XP"
*/
export function formatXPGain(xpGained: number): string {
if (xpGained <= 0) return '';
return `+${xpGained} XP`;
}
/**
* Get a descriptive message about XP gain.
*
* @param action - The action that earned XP
* @param xpGained - Amount of XP gained
* @param newTotal - New total XP (optional, for "You now have X XP" message)
* @returns Formatted message for user feedback
*/
export function getXPGainMessage(
action: BlobbiAction,
xpGained: number,
newTotal?: number
): string {
if (xpGained <= 0) return '';
const xpText = formatXPGain(xpGained);
if (newTotal !== undefined) {
return `${xpText} earned! Total: ${newTotal} XP`;
}
return `${xpText} earned!`;
}
@@ -8,7 +8,7 @@
* We replace gradient colors, not the gradient structure.
*/
import type { Blobbi } from '@/types/blobbi';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import type { AdultForm, AdultSvgCustomization } from '../types/adult.types';
// ─── Color Utilities ──────────────────────────────────────────────────────────
@@ -5,7 +5,7 @@
* Each adult form has its own folder with base and sleeping variants.
*/
import type { Blobbi } from '@/types/blobbi';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import {
type AdultForm,
type AdultSvgResolverOptions,
+1 -1
View File
@@ -4,7 +4,7 @@
* Type definitions for adult stage visuals and customization
*/
import type { Blobbi } from '@/types/blobbi';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
/**
* All available adult evolution forms.
@@ -4,7 +4,7 @@
* Handles applying colors and customizations to baby SVG content
*/
import { Blobbi } from '@/types/blobbi';
import { Blobbi } from '@/blobbi/core/types/blobbi';
import { BabySvgCustomization } from '../types/baby.types';
/**
@@ -4,7 +4,7 @@
* Handles loading and resolving baby stage SVG assets
*/
import { Blobbi } from '@/types/blobbi';
import { Blobbi } from '@/blobbi/core/types/blobbi';
import { BabyVariant, BabySvgResolverOptions } from '../types/baby.types';
import { BABY_BASE_SVG, BABY_SLEEPING_SVG } from './baby-svg-data';
+1 -1
View File
@@ -4,7 +4,7 @@
* Type definitions for baby stage visuals and customization
*/
import { Blobbi } from '@/types/blobbi';
import { Blobbi } from '@/blobbi/core/types/blobbi';
/**
* Baby visual variant types
@@ -12,7 +12,7 @@ import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual';
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
import { cn } from '@/lib/utils';
import type { CompanionData, EyeOffset, CompanionDirection } from '../types/companion.types';
import type { Blobbi } from '@/types/blobbi';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
interface BlobbiCompanionVisualProps {
/** Companion data */
@@ -19,7 +19,7 @@ import {
KIND_BLOBBI_STATE,
isValidBlobbiEvent,
parseBlobbiEvent,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import type { CompanionData } from '../types/companion.types';
interface UseBlobbiCompanionDataResult {
@@ -24,7 +24,7 @@ import {
isValidBlobbiEvent,
parseBlobbiEvent,
type BlobbiStats,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import { checkItemCategoryNeed, type NeedCheckResult } from '../interaction/needDetection';
import type { ShopItemCategory } from '@/blobbi/shop/types/shop.types';
import type { Position } from '../types/companion.types';
@@ -10,7 +10,7 @@
* - Returns both boolean need and priority level for potential future use
*/
import type { BlobbiStats } from '@/lib/blobbi';
import type { BlobbiStats } from '@/blobbi/core/lib/blobbi';
import type { ShopItemCategory } from '@/blobbi/shop/types/shop.types';
// ─── Need Thresholds ──────────────────────────────────────────────────────────
@@ -27,7 +27,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { NostrEvent } from '@nostrify/nostrify';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
@@ -36,8 +36,8 @@ import {
createStorageTags,
parseBlobbiEvent,
isValidBlobbiEvent,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
@@ -19,7 +19,7 @@ import { useLocation } from 'react-router-dom';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import type { StorageItem } from '@/lib/blobbi';
import type { StorageItem } from '@/blobbi/core/lib/blobbi';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import type {
@@ -5,7 +5,7 @@
* decoupled from app-specific concerns.
*/
import type { BlobbiVisualTraits } from '@/lib/blobbi';
import type { BlobbiVisualTraits } from '@/blobbi/core/lib/blobbi';
// ─── Companion State Machine ──────────────────────────────────────────────────
+201
View File
@@ -0,0 +1,201 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import {
KIND_BLOBBI_STATE,
BLOBBI_CACHE_KEY,
isValidBlobbiEvent,
parseBlobbiEvent,
isLegacyBlobbiD,
isCanonicalBlobbiD,
type BlobbiBootCache,
type BlobbiCompanion,
} from '../lib/blobbi';
interface UseBlobbiCompanionOptions {
/** The d-tag value of the companion to fetch (from current_companion or has[] in profile) */
companionD: string | undefined;
}
/**
* Hook to fetch and manage a Blobbi Companion (Kind 31124) by its d-tag.
*
* Features:
* - localStorage boot cache for instant UI on page load
* - Fetches from relays with legacy d-tag support
* - Detects legacy pets that need migration
* - Prevents duplicate fetches and query loops
* - Provides the parsed companion or null if none exists
*/
export function useBlobbiCompanion({ companionD }: UseBlobbiCompanionOptions) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
// Boot cache in localStorage
const [bootCache, setBootCache] = useLocalStorage<BlobbiBootCache | null>(
BLOBBI_CACHE_KEY,
null
);
// Track if we've already applied the boot cache
const bootCacheApplied = useRef(false);
// Track last fetched to prevent refetching on re-renders
const lastFetchKey = useRef<string | null>(null);
// Get the cached companion immediately on mount
// Validate that the cache belongs to the current user and matches the requested d-tag
const cachedCompanion = useMemo((): BlobbiCompanion | null => {
if (!bootCache || !user?.pubkey || !companionD) {
return null;
}
// Validate cache ownership
if (bootCache.pubkey !== user.pubkey) {
return null;
}
if (!bootCache.companion) {
return null;
}
// Verify the cached companion matches the requested d-tag
if (bootCache.companion.d !== companionD) {
return null;
}
// Verify the cached companion event belongs to the current user
if (bootCache.companion.event.pubkey !== user.pubkey) {
return null;
}
return bootCache.companion;
}, [bootCache, user?.pubkey, companionD]);
// Main query to fetch the companion from relays
const query = useQuery({
queryKey: ['blobbi-companion', user?.pubkey, companionD],
queryFn: async ({ signal }) => {
if (!user?.pubkey || !companionD) return null;
const events = await nostr.query(
[{
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': [companionD],
}],
{ signal }
);
// Filter to valid events and find the newest
const validEvents = events
.filter(isValidBlobbiEvent)
.sort((a, b) => b.created_at - a.created_at);
if (validEvents.length === 0) return null;
const latestEvent = validEvents[0];
lastFetchKey.current = `${user.pubkey}:${companionD}`;
return parseBlobbiEvent(latestEvent) ?? null;
},
enabled: !!user?.pubkey && !!companionD,
staleTime: 30_000, // 30 seconds - don't refetch if data is fresh
gcTime: 5 * 60 * 1000, // 5 minutes garbage collection
refetchOnWindowFocus: false, // Prevent unnecessary refetches
refetchOnReconnect: true, // Refetch when connection is restored
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// Use cached companion as initial data for instant UI
initialData: cachedCompanion ?? undefined,
placeholderData: cachedCompanion ?? undefined,
});
// Update boot cache when we get fresh data from relays
useEffect(() => {
if (query.data && !query.isPlaceholderData && user?.pubkey) {
// Verify the data belongs to the current user before caching
if (query.data.event.pubkey === user.pubkey) {
setBootCache(prev => ({
pubkey: user.pubkey,
profile: prev?.pubkey === user.pubkey ? prev.profile : null,
companion: query.data,
cachedAt: Date.now(),
}));
}
}
}, [query.data, query.isPlaceholderData, user?.pubkey, setBootCache]);
// Apply boot cache on first mount
useEffect(() => {
if (cachedCompanion && !bootCacheApplied.current) {
bootCacheApplied.current = true;
}
}, [cachedCompanion]);
// Reset tracking when companion changes
useEffect(() => {
const currentKey = user?.pubkey && companionD ? `${user.pubkey}:${companionD}` : null;
if (currentKey !== lastFetchKey.current) {
bootCacheApplied.current = false;
}
}, [user?.pubkey, companionD]);
// Helper to invalidate and refetch after publishing
const invalidate = useCallback(() => {
if (user?.pubkey && companionD) {
queryClient.invalidateQueries({
queryKey: ['blobbi-companion', user.pubkey, companionD],
});
}
}, [queryClient, user?.pubkey, companionD]);
// Update the companion event in the query cache (optimistic update)
const updateCompanionEvent = useCallback((event: NostrEvent) => {
const parsed = parseBlobbiEvent(event);
if (parsed && user?.pubkey) {
queryClient.setQueryData(['blobbi-companion', user.pubkey, parsed.d], parsed);
// Also update boot cache
setBootCache(prev => ({
pubkey: user.pubkey,
profile: prev?.pubkey === user.pubkey ? prev.profile : null,
companion: parsed,
cachedAt: Date.now(),
}));
}
}, [queryClient, user?.pubkey, setBootCache]);
// Determine if the current companion needs migration to canonical format
const needsMigration = useMemo(() => {
if (!query.data?.d) return false;
return isLegacyBlobbiD(query.data.d);
}, [query.data?.d]);
// Check if the companion is in canonical format
const isCanonical = useMemo(() => {
if (!query.data?.d) return false;
return isCanonicalBlobbiD(query.data.d);
}, [query.data?.d]);
return {
companion: query.data ?? null,
/** True only when we have no cached data AND query is loading */
isLoading: query.isLoading && !cachedCompanion,
/** True when actively fetching (may have cached data displayed) */
isFetching: query.isFetching,
/** True when displaying stale data */
isStale: query.isStale,
error: query.error,
invalidate,
updateCompanionEvent,
/** Whether we're showing cached data while fetching fresh data */
isFromCache: !!cachedCompanion && query.isFetching,
/** Whether this companion needs migration to canonical format */
needsMigration,
/** Whether this companion is in canonical format */
isCanonical,
};
}
+299
View File
@@ -0,0 +1,299 @@
import { useCallback } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/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<MigrationResult | null> => {
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<string, string | string[]> = {
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<EnsureCanonicalResult | null> => {
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,
};
}
@@ -0,0 +1,198 @@
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 '@/hooks/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<T>(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<string, NostrEvent>();
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<string, BlobbiCompanion> = {};
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<string, BlobbiCompanion>; 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,
};
}
@@ -0,0 +1,122 @@
/**
* 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 '@/blobbi/core/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),
});
}
+563
View File
@@ -0,0 +1,563 @@
/**
* 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<BlobbiStats>;
/** Unix timestamp of last decay application */
lastDecayAt: number | undefined;
/** Current unix timestamp (defaults to now) */
now?: number;
}
// ─── Constants: Decay Rates ───────────────────────────────────────────────────
/**
* Egg stage decay rates (per hour).
*
* Design goal: Needs attention every 2-3 hours.
*
* Notes:
* - hunger and energy are fixed at 100 for eggs
* - hygiene decays at 8/hr → reaches warning (75) in ~3.1 hours
* - health has conditional decay based on hygiene
* - happiness depends on health and hygiene state
*/
const EGG_DECAY = {
hygiene: -8.0, // Base hygiene decay
health: {
base: -1.0, // Base health decay
hygieneBelow70: -2.0, // Extra if hygiene < 70
hygieneBelow40: -3.0, // Extra if hygiene < 40
},
happiness: {
// Happiness is calculated after health/hygiene are updated
healthyAndClean: 2.0, // health >= 70 AND hygiene >= 70
moderate: -2.0, // health >= 40 AND hygiene >= 40
poor: -4.0, // otherwise
},
} as const;
/**
* 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<BlobbiStats>, 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;
}
/**
* Round a stat delta toward zero (truncate fractional part).
*
* CRITICAL: We use Math.trunc() instead of Math.floor() because:
* - Math.floor(-0.5) = -1 (rounds down, applying decay even with tiny elapsed time)
* - Math.trunc(-0.5) = 0 (rounds toward zero, no decay applied)
*
* This prevents the bug where any action within seconds of the last action
* would still apply -1 decay even though insufficient time passed.
*
* @param delta - Calculated stat change (can be positive or negative)
* @returns Integer delta to apply
*/
function roundDelta(delta: number): number {
return Math.trunc(delta);
}
// ─── 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<BlobbiStats>,
elapsedHours: number
): BlobbiStats {
// Get current values
let hygiene = getStat(stats, 'hygiene');
let health = getStat(stats, 'health');
let happiness = getStat(stats, 'happiness');
// Calculate hygiene decay first
const hygieneDelta = EGG_DECAY.hygiene * elapsedHours;
hygiene = clamp(hygiene + roundDelta(hygieneDelta));
// Calculate health decay (depends on current hygiene)
let healthDelta = EGG_DECAY.health.base * elapsedHours;
if (hygiene < 70) {
healthDelta += EGG_DECAY.health.hygieneBelow70 * elapsedHours;
}
if (hygiene < 40) {
healthDelta += EGG_DECAY.health.hygieneBelow40 * elapsedHours;
}
health = clamp(health + roundDelta(healthDelta));
// Calculate happiness (depends on updated health and hygiene)
let happinessDelta: number;
if (health >= 70 && hygiene >= 70) {
happinessDelta = EGG_DECAY.happiness.healthyAndClean * elapsedHours;
} else if (health >= 40 && hygiene >= 40) {
happinessDelta = EGG_DECAY.happiness.moderate * elapsedHours;
} else {
happinessDelta = EGG_DECAY.happiness.poor * elapsedHours;
}
happiness = clamp(happiness + roundDelta(happinessDelta));
return {
hunger: 100, // Fixed for eggs
energy: 100, // Fixed for eggs
hygiene,
health,
happiness,
};
}
/**
* Calculate baby stage decay.
*/
function calculateBabyDecay(
stats: Partial<BlobbiStats>,
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 + roundDelta(hungerDelta));
happiness = clamp(happiness + roundDelta(happinessDelta));
hygiene = clamp(hygiene + roundDelta(hygieneDelta));
energy = clamp(energy + roundDelta(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 + roundDelta(healthDelta));
return { hunger, happiness, hygiene, energy, health };
}
/**
* Calculate adult stage decay.
*/
function calculateAdultDecay(
stats: Partial<BlobbiStats>,
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 + roundDelta(hungerDelta));
happiness = clamp(happiness + roundDelta(happinessDelta));
hygiene = clamp(hygiene + roundDelta(hygieneDelta));
energy = clamp(energy + roundDelta(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 + roundDelta(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. Truncates all stat deltas toward zero before application (prevents micro-decay from tiny elapsed times)
* 4. Clamps final stats to 1-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<string, number>)[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<string, number>)[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<BlobbiStats>
): 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 ─────────────────────────────────────────────────────
/**
* 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.
*/
export function getVisibleStatsWithValues(
stage: BlobbiStage,
stats: Partial<BlobbiStats>
): 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),
}));
}
+156
View File
@@ -0,0 +1,156 @@
/**
* 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<EggVisualBlobbi['lifeStage']>;
/** Pattern values accepted by EggGraphic */
type EggPattern = NonNullable<EggVisualBlobbi['pattern']>;
/** Special mark values accepted by EggGraphic */
type EggSpecialMark = NonNullable<EggVisualBlobbi['specialMark']>;
/** Theme variant values accepted by EggGraphic */
type EggThemeVariant = NonNullable<EggVisualBlobbi['themeVariant']>;
// ─── Mapping Tables ───────────────────────────────────────────────────────────
/**
* Maps Blobbi pattern values to EggGraphic pattern values.
* Explicit mapping allows vocabularies to diverge in the future.
*/
const PATTERN_MAP: Record<BlobbiPattern, EggPattern> = {
'solid': 'solid',
'spotted': 'spotted',
'striped': 'striped',
'gradient': 'gradient',
};
/**
* Maps Blobbi special mark values to EggGraphic special mark values.
*/
const SPECIAL_MARK_MAP: Record<BlobbiSpecialMark, EggSpecialMark> = {
'none': 'none',
'star': 'star',
'heart': 'heart',
'sparkle': 'sparkle',
'blush': 'blush',
};
/**
* Maps Blobbi stage values to EggGraphic life stage values.
*/
const LIFE_STAGE_MAP: Record<BlobbiStage, EggLifeStage> = {
'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
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
// src/types/blobbi.ts
/**
* Minimal, clean Blobbi domain types for the new project.
*
* Goal:
* - keep the model small and portable
* - support egg / baby / adult rendering
* - support sleep state
* - support visual customization
* - avoid dragging old project complexity into the new app
*/
/* ────────────────────────────────────────────────────────────────────────── *
* Core lifecycle / state
* ────────────────────────────────────────────────────────────────────────── */
export type BlobbiLifeStage = 'egg' | 'baby' | 'adult';
export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving';
/* ────────────────────────────────────────────────────────────────────────── *
* Visual traits
* ────────────────────────────────────────────────────────────────────────── */
export type BlobbiPattern = 'solid' | 'spotted' | 'striped' | 'gradient';
export type BlobbiSpecialMark = 'none' | 'star' | 'heart' | 'sparkle' | 'blush';
export type BlobbiSize = 'small' | 'medium' | 'large';
export interface BlobbiVisualTraits {
/**
* Main body/base color.
* Example: "#8B5CF6"
*/
baseColor?: string;
/**
* Secondary/accent color, usually used in gradients or details.
*/
secondaryColor?: string;
/**
* Eye / pupil color.
*/
eyeColor?: string;
/**
* Optional pattern used by egg or future visual systems.
*/
pattern?: BlobbiPattern;
/**
* Optional visual mark.
*/
specialMark?: BlobbiSpecialMark;
/**
* Optional size hint for rendering.
*/
size?: BlobbiSize;
}
/* ────────────────────────────────────────────────────────────────────────── *
* Basic stats
* Keep only what is useful right now for UI and simple interactions.
* ────────────────────────────────────────────────────────────────────────── */
export interface BlobbiStats {
hunger: number;
happiness: number;
health: number;
hygiene: number;
energy: number;
}
/* ────────────────────────────────────────────────────────────────────────── *
* Stage-specific fields
* ────────────────────────────────────────────────────────────────────────── */
export interface BlobbiEggData {
incubationTime?: number;
incubationProgress?: number;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface BlobbiBabyData {
// Reserved for future baby-specific fields
}
export interface BlobbiAdultData {
evolutionForm?: string;
}
/* ────────────────────────────────────────────────────────────────────────── *
* Main Blobbi entity
* ────────────────────────────────────────────────────────────────────────── */
export interface Blobbi extends BlobbiVisualTraits {
/**
* Stable unique identifier.
*/
id: string;
/**
* Display name.
*/
name: string;
/**
* Current lifecycle stage.
*/
lifeStage: BlobbiLifeStage;
/**
* Current activity state.
*/
state: BlobbiState;
/**
* Optional convenience boolean for UI code that still expects this.
* Prefer using `state === "sleeping"` in new code.
*/
isSleeping?: boolean;
/**
* Basic gameplay / care stats.
*/
stats: BlobbiStats;
/**
* Ownership / identity metadata.
*/
ownerPubkey?: string;
seed?: string;
/**
* Timestamps.
* Keep them simple for now; decide later whether the project will
* standardize on seconds or milliseconds everywhere.
*/
createdAt?: number;
birthTime?: number;
hatchTime?: number;
lastInteraction?: number;
/**
* Progression.
*/
experience?: number;
generation?: number;
careStreak?: number;
/**
* Visibility / social.
*/
visibleToOthers?: boolean;
crossoverApp?: string | null;
themeVariant?: string;
/**
* Optional raw tags for Nostr-backed or metadata-driven rendering.
*/
tags?: string[][];
/**
* Optional stage-specific buckets.
* This keeps the root model clean while leaving room to grow.
*/
egg?: BlobbiEggData;
baby?: BlobbiBabyData;
adult?: BlobbiAdultData;
}
/* ────────────────────────────────────────────────────────────────────────── *
* Defaults / helpers
* ────────────────────────────────────────────────────────────────────────── */
export const DEFAULT_BLOBBI_STATS: BlobbiStats = {
hunger: 100,
happiness: 100,
health: 100,
hygiene: 100,
energy: 100,
};
export const DEFAULT_BLOBBI_STATE: BlobbiState = 'active';
export const DEFAULT_BLOBBI_LIFE_STAGE: BlobbiLifeStage = 'egg';
export function createDefaultBlobbi(overrides: Partial<Blobbi> = {}): Blobbi {
const state = overrides.state ?? DEFAULT_BLOBBI_STATE;
return {
id: overrides.id ?? 'blobbi-1',
name: overrides.name ?? 'Blobbi',
lifeStage: overrides.lifeStage ?? DEFAULT_BLOBBI_LIFE_STAGE,
state,
isSleeping: overrides.isSleeping ?? state === 'sleeping',
stats: overrides.stats ?? { ...DEFAULT_BLOBBI_STATS },
baseColor: overrides.baseColor,
secondaryColor: overrides.secondaryColor,
eyeColor: overrides.eyeColor,
pattern: overrides.pattern,
specialMark: overrides.specialMark,
size: overrides.size,
ownerPubkey: overrides.ownerPubkey,
seed: overrides.seed,
createdAt: overrides.createdAt,
birthTime: overrides.birthTime,
hatchTime: overrides.hatchTime,
lastInteraction: overrides.lastInteraction,
experience: overrides.experience ?? 0,
generation: overrides.generation ?? 1,
careStreak: overrides.careStreak ?? 0,
visibleToOthers: overrides.visibleToOthers ?? true,
crossoverApp: overrides.crossoverApp ?? null,
themeVariant: overrides.themeVariant,
tags: overrides.tags ?? [],
egg: overrides.egg,
baby: overrides.baby,
adult: overrides.adult,
};
}
/* ────────────────────────────────────────────────────────────────────────── *
* Type guards
* ────────────────────────────────────────────────────────────────────────── */
export function isEggBlobbi(blobbi: Blobbi): boolean {
return blobbi.lifeStage === 'egg';
}
export function isBabyBlobbi(blobbi: Blobbi): boolean {
return blobbi.lifeStage === 'baby';
}
export function isAdultBlobbi(blobbi: Blobbi): boolean {
return blobbi.lifeStage === 'adult';
}
export function isBlobbiSleeping(blobbi: Blobbi): boolean {
return blobbi.state === 'sleeping' || blobbi.isSleeping === true;
}
+1 -1
View File
@@ -22,7 +22,7 @@ import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import type { BlobbiCompanion, BlobbiStage, BlobbiState, BlobbiStats } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbiStage, BlobbiState, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import { ADULT_FORMS } from '@/blobbi/adult-blobbi/types/adult.types';
// ─── Types ────────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -14,8 +14,8 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbiStage } from '@/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags, getLocalDayString } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbiStage } from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags, getLocalDayString } from '@/blobbi/core/lib/blobbi';
import type { BlobbiDevUpdates } from './BlobbiDevEditor';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -16,7 +16,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { BLOBBI_ADOPTION_COST } from '@/lib/blobbi';
import { BLOBBI_ADOPTION_COST } from '@/blobbi/core/lib/blobbi';
import { formatCompactNumber } from '@/lib/utils';
import type { BlobbiEggPreview } from '../lib/blobbi-preview';
@@ -19,7 +19,7 @@ import { cn, formatCompactNumber } from '@/lib/utils';
import {
BLOBBI_PREVIEW_REROLL_COST,
BLOBBI_ADOPTION_COST,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import type { BlobbiEggPreview } from '../lib/blobbi-preview';
import { previewToBlobbiCompanion } from '../lib/blobbi-preview';
@@ -26,7 +26,7 @@ import { BlobbiEggPreviewCard } from './BlobbiEggPreviewCard';
import { BlobbiAdoptionConfirmDialog } from './BlobbiAdoptionConfirmDialog';
import { Loader2 } from 'lucide-react';
import type { BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
interface BlobbiOnboardingFlowProps {
/** Current profile (null if doesn't exist) */
@@ -31,7 +31,7 @@ import {
buildBlobbonautTags,
updateBlobbonautTags,
type BlobbonautProfile,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import {
generateEggPreview,
+1 -1
View File
@@ -16,7 +16,7 @@ import {
getLocalDayString,
type BlobbiVisualTraits,
type BlobbiStats,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -18,7 +18,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { ShopItem } from '../types/shop.types';
import { getShopItemById } from '../lib/blobbi-shop-items';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
@@ -14,7 +14,7 @@ import { BlobbiShopItemRow } from './BlobbiShopItemRow';
import { BlobbiPurchaseDialog } from './BlobbiPurchaseDialog';
import type { ShopItem, ShopItemCategory } from '../types/shop.types';
import type { BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { getShopItemsByType } from '../lib/blobbi-shop-items';
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
import { cn, formatCompactNumber } from '@/lib/utils';
@@ -5,12 +5,12 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { PurchaseRequest } from '../types/shop.types';
import type { BlobbonautProfile, StorageItem } from '@/lib/blobbi';
import type { BlobbonautProfile, StorageItem } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbonautTags,
createStorageTags,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import { getShopItemById } from '../lib/blobbi-shop-items';
/**
+2 -2
View File
@@ -16,8 +16,8 @@ import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
import { addEyeAnimation } from './lib/eye-animation';
import { applyEmotion, type BlobbiEmotion } from './lib/emotions';
import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes';
import type { Blobbi } from '@/types/blobbi';
import { isBlobbiSleeping } from '@/types/blobbi';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import { isBlobbiSleeping } from '@/blobbi/core/types/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -13,9 +13,9 @@ import { addEyeAnimation } from './lib/eye-animation';
import { applyEmotion, type BlobbiEmotion } from './lib/emotions';
import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes';
import { cn } from '@/lib/utils';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import { isBlobbiSleeping } from '@/blobbi/core/types/blobbi';
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
import type { Blobbi } from '@/types/blobbi';
import { isBlobbiSleeping } from '@/types/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -14,9 +14,9 @@
import { useMemo } from 'react';
import { EggGraphic, type EggReactionState } from '@/blobbi/egg';
import { toEggGraphicVisualBlobbi } from '@/lib/blobbi-egg-adapter';
import { toEggGraphicVisualBlobbi } from '@/blobbi/core/lib/blobbi-egg-adapter';
import { cn } from '@/lib/utils';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -27,7 +27,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { toast } from '@/hooks/useToast';
import { trackDailyMissionProgress } from '@/blobbi/actions';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -15,7 +15,7 @@ import { forwardRef } from 'react';
import { BlobbiStageVisual } from './BlobbiStageVisual';
import { cn } from '@/lib/utils';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -16,8 +16,8 @@ import { BlobbiBabyVisual } from './BlobbiBabyVisual';
import { BlobbiAdultVisual } from './BlobbiAdultVisual';
import { FloatingMusicNotes } from './FloatingMusicNotes';
import { cn } from '@/lib/utils';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { Blobbi } from '@/types/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import type { BlobbiLookMode } from './lib/useBlobbiEyes';
import type { BlobbiEmotion } from './lib/emotions';
+1 -1
View File
@@ -15,7 +15,7 @@ import {
parseBlobbonautEvent,
type BlobbiBootCache,
type BlobbonautProfile,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
/**
* Hook to fetch and manage the Blobbonaut Profile for the logged-in user.
@@ -20,7 +20,7 @@ import {
buildNormalizedProfileTags,
isLegacyBlobbonautKind,
type BlobbonautProfile,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
interface UseBlobbonautProfileNormalizationOptions {
/** The current profile (null if doesn't exist) */
+6 -6
View File
@@ -8,14 +8,14 @@ import { Egg, Moon, Sun, Eye, EyeOff, Loader2, RefreshCw, Check, Info, Users, Ta
// Note: AlertTriangle kept for stat warning indicators
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProjectedBlobbiState } from '@/hooks/useProjectedBlobbiState';
import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState';
import { useAppContext } from '@/hooks/useAppContext';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
import { useBlobbonautProfileNormalization } from '@/hooks/useBlobbonautProfileNormalization';
import { useBlobbisCollection } from '@/hooks/useBlobbisCollection';
import { useBlobbisCollection } from '@/blobbi/core/hooks/useBlobbisCollection';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useBlobbiMigration } from '@/hooks/useBlobbiMigration';
import { useBlobbiMigration } from '@/blobbi/core/hooks/useBlobbiMigration';
import { toast } from '@/hooks/useToast';
import { LoginArea } from '@/components/auth/LoginArea';
@@ -37,9 +37,9 @@ import {
updateBlobbonautTags,
type BlobbiCompanion,
type BlobbonautProfile,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { BlobbiShopModal } from '@/blobbi/shop/components/BlobbiShopModal';
import { BlobbiInventoryModal } from '@/blobbi/shop/components/BlobbiInventoryModal';
@@ -785,7 +785,7 @@ interface BlobbiDashboardProps {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
// DEV ONLY: State editor props
showDevEditor: boolean;