Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56866c7de9 |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
getLocalDayString,
|
||||
getDaysDifference,
|
||||
type BlobbiCompanion,
|
||||
} from '@/lib/blobbi';
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user