Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 834038dba5 | |||
| d6f89d206e | |||
| 0249760b74 | |||
| 377a6cbb97 |
@@ -1,7 +1,7 @@
|
||||
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
import { Loader2, X, Clock } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -16,7 +16,7 @@ import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobb
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
filterInventoryByAction,
|
||||
getItemsForAction,
|
||||
previewStatChanges,
|
||||
previewMedicineForEgg,
|
||||
previewCleanForEgg,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type ResolvedInventoryItem,
|
||||
type EggStatPreview,
|
||||
} from '../lib/blobbi-action-utils';
|
||||
import { useItemCooldown } from '../hooks/useItemCooldown';
|
||||
|
||||
interface BlobbiActionInventoryModalProps {
|
||||
open: boolean;
|
||||
@@ -53,11 +54,11 @@ export function BlobbiActionInventoryModal({
|
||||
usingItemId,
|
||||
}: BlobbiActionInventoryModalProps) {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const { isOnCooldown } = useItemCooldown();
|
||||
|
||||
// Get all available items for this action from the catalog (not inventory).
|
||||
// Items are abilities/tools — no ownership required.
|
||||
// Get all available items for this action from the catalog.
|
||||
const availableItems = useMemo(() => {
|
||||
return filterInventoryByAction([], action, { stage: companion.stage });
|
||||
return getItemsForAction(action, { stage: companion.stage });
|
||||
}, [action, companion.stage]);
|
||||
|
||||
// Check stage restrictions for this specific action
|
||||
@@ -68,6 +69,7 @@ export function BlobbiActionInventoryModal({
|
||||
|
||||
const handleUseItem = (item: ResolvedInventoryItem) => {
|
||||
if (isUsingItem) return;
|
||||
if (isOnCooldown(item.itemId)) return;
|
||||
onUseItem(item.itemId);
|
||||
};
|
||||
|
||||
@@ -126,17 +128,22 @@ export function BlobbiActionInventoryModal({
|
||||
{/* Item List */}
|
||||
{canUse && !isEmpty && (
|
||||
<div className="grid gap-3">
|
||||
{availableItems.map((item) => (
|
||||
<BlobbiInventoryUseRow
|
||||
key={item.itemId}
|
||||
item={item}
|
||||
companion={companion}
|
||||
action={action}
|
||||
onUse={() => handleUseItem(item)}
|
||||
isUsing={isUsingItem && usingItemId === item.itemId}
|
||||
disabled={isUsingItem}
|
||||
/>
|
||||
))}
|
||||
{availableItems.map((item) => {
|
||||
const isCoolingDown = isOnCooldown(item.itemId);
|
||||
const isThisUsing = isUsingItem && usingItemId === item.itemId;
|
||||
return (
|
||||
<BlobbiItemUseRow
|
||||
key={item.itemId}
|
||||
item={item}
|
||||
companion={companion}
|
||||
action={action}
|
||||
onUse={() => handleUseItem(item)}
|
||||
isUsing={isThisUsing}
|
||||
disabled={isUsingItem || isCoolingDown}
|
||||
isCoolingDown={isCoolingDown}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -145,30 +152,32 @@ export function BlobbiActionInventoryModal({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Inventory Use Row ────────────────────────────────────────────────────────
|
||||
// ─── Item Use Row ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiInventoryUseRowProps {
|
||||
interface BlobbiItemUseRowProps {
|
||||
item: ResolvedInventoryItem;
|
||||
companion: BlobbiCompanion;
|
||||
action: InventoryAction;
|
||||
onUse: () => void;
|
||||
isUsing: boolean;
|
||||
disabled: boolean;
|
||||
isCoolingDown: boolean;
|
||||
}
|
||||
|
||||
function BlobbiInventoryUseRow({
|
||||
function BlobbiItemUseRow({
|
||||
item,
|
||||
companion,
|
||||
action,
|
||||
onUse,
|
||||
isUsing,
|
||||
disabled,
|
||||
}: BlobbiInventoryUseRowProps) {
|
||||
isCoolingDown,
|
||||
}: BlobbiItemUseRowProps) {
|
||||
const isEgg = companion.stage === 'egg';
|
||||
const isMedicine = action === 'medicine';
|
||||
const isClean = action === 'clean';
|
||||
|
||||
// Preview stat changes - handle egg-specific preview for medicine and clean
|
||||
// Preview stat changes — single-use effect preview
|
||||
const { normalStatChanges, eggStatChanges } = useMemo(() => {
|
||||
if (isEgg && isMedicine) {
|
||||
return {
|
||||
@@ -217,38 +226,18 @@ function BlobbiInventoryUseRow({
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{normalStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
<span className={cn('font-medium', delta > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
|
||||
{delta > 0 ? '+' : ''}{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-muted-foreground capitalize">{stat.replace('_', ' ')}</span>
|
||||
</span>
|
||||
))}
|
||||
{eggStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
<span className={cn('font-medium', delta > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
|
||||
{delta > 0 ? '+' : ''}{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-muted-foreground capitalize">{stat.replace('_', ' ')}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -261,10 +250,13 @@ function BlobbiInventoryUseRow({
|
||||
size="sm"
|
||||
onClick={onUse}
|
||||
disabled={disabled}
|
||||
className="shrink-0"
|
||||
variant={isCoolingDown ? 'outline' : 'default'}
|
||||
className="shrink-0 min-w-14"
|
||||
>
|
||||
{isUsing ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : isCoolingDown ? (
|
||||
<Clock className="size-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
'Use'
|
||||
)}
|
||||
@@ -276,38 +268,18 @@ function BlobbiInventoryUseRow({
|
||||
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
|
||||
{normalStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
<span className={cn('font-medium', delta > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
|
||||
{delta > 0 ? '+' : ''}{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-muted-foreground capitalize">{stat.replace('_', ' ')}</span>
|
||||
</span>
|
||||
))}
|
||||
{eggStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
delta > 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta}
|
||||
<span className={cn('font-medium', delta > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
|
||||
{delta > 0 ? '+' : ''}{delta}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground capitalize">
|
||||
{stat.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-muted-foreground capitalize">{stat.replace('_', ' ')}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -29,11 +29,12 @@ 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 { isItemOnCooldown, setItemCooldown } from '../lib/item-cooldown';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
|
||||
|
||||
/**
|
||||
* Request payload for using an item on a Blobbi companion
|
||||
* Request payload for using an item on a Blobbi companion.
|
||||
*/
|
||||
export interface UseItemRequest {
|
||||
itemId: string;
|
||||
@@ -41,7 +42,7 @@ export interface UseItemRequest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of using an item on a Blobbi companion
|
||||
* Result of using an item on a Blobbi companion.
|
||||
*/
|
||||
export interface UseItemResult {
|
||||
itemName: string;
|
||||
@@ -79,15 +80,19 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Hook to use an item on a Blobbi companion.
|
||||
*
|
||||
*
|
||||
* Items are reusable abilities sourced from the shop catalog — no
|
||||
* inventory ownership or quantity is required.
|
||||
*
|
||||
* ownership or quantity is required. Each use applies effects once.
|
||||
*
|
||||
* Cooldown is enforced via the shared item-cooldown module so that
|
||||
* rapid repeated clicks are blocked consistently across all UIs.
|
||||
*
|
||||
* This hook:
|
||||
* 1. Validates the companion and item compatibility
|
||||
* 2. Ensures canonical format before action
|
||||
* 3. Applies accumulated decay, then item effects to Blobbi stats
|
||||
* 4. Updates Blobbi state (kind 31124)
|
||||
* 2. Checks the shared per-item cooldown
|
||||
* 3. Ensures canonical format before action
|
||||
* 4. Applies accumulated decay, then item effects to Blobbi stats
|
||||
* 5. Updates Blobbi state (kind 31124)
|
||||
*/
|
||||
export function useBlobbiUseInventoryItem({
|
||||
companion,
|
||||
@@ -101,6 +106,11 @@ export function useBlobbiUseInventoryItem({
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ itemId, action }: UseItemRequest): Promise<UseItemResult> => {
|
||||
// ─── Cooldown guard (shared across all UIs) ───
|
||||
if (isItemOnCooldown(itemId)) {
|
||||
throw new Error('Please wait before using this item again');
|
||||
}
|
||||
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to use items');
|
||||
@@ -147,9 +157,6 @@ export function useBlobbiUseInventoryItem({
|
||||
}
|
||||
|
||||
// ─── Apply Accumulated Decay First ───
|
||||
// Per decay-system.md: Always apply accumulated decay from persisted state
|
||||
// before any user interaction updates stats.
|
||||
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
@@ -163,7 +170,6 @@ export function useBlobbiUseInventoryItem({
|
||||
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;
|
||||
@@ -174,8 +180,6 @@ export function useBlobbiUseInventoryItem({
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -194,8 +198,7 @@ export function useBlobbiUseInventoryItem({
|
||||
const statsChanged: Record<string, number> = {};
|
||||
|
||||
if (isEggCompanion && action === 'medicine') {
|
||||
const healthDelta = shopItem.effect.health ?? 0;
|
||||
const currentHealth = applyStat(statsAfterDecay.health ?? 0, healthDelta);
|
||||
const currentHealth = applyStat(statsAfterDecay.health ?? 0, shopItem.effect.health ?? 0);
|
||||
|
||||
statsUpdate.health = currentHealth.toString();
|
||||
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
|
||||
@@ -243,7 +246,6 @@ export function useBlobbiUseInventoryItem({
|
||||
// ─── Update Blobbi State Event (kind 31124) ───
|
||||
const nowStr = now.toString();
|
||||
|
||||
// If incubating or evolving, increment the interaction counter for tasks
|
||||
const companionState = canonical.companion.state;
|
||||
let updatedTags = canonical.allTags;
|
||||
if (companionState === 'incubating') {
|
||||
@@ -252,7 +254,6 @@ export function useBlobbiUseInventoryItem({
|
||||
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
|
||||
}
|
||||
|
||||
// Get streak updates (will only update if needed based on day)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
// ─── Apply XP Gain ───
|
||||
@@ -276,11 +277,6 @@ export function useBlobbiUseInventoryItem({
|
||||
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// Items are free to use — no storage decrement needed.
|
||||
// No query invalidation needed — the optimistic update above keeps the
|
||||
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
|
||||
// before every mutation (read-modify-write pattern).
|
||||
|
||||
return {
|
||||
itemName: shopItem.name,
|
||||
action,
|
||||
@@ -289,7 +285,7 @@ export function useBlobbiUseInventoryItem({
|
||||
newXP,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ itemName, action, xpGained }) => {
|
||||
onSuccess: ({ itemName, action, xpGained }, { itemId }) => {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const xpText = formatXPGain(xpGained);
|
||||
toast({
|
||||
@@ -297,19 +293,24 @@ export function useBlobbiUseInventoryItem({
|
||||
description: `Used ${itemName} on your Blobbi. ${xpText}`,
|
||||
});
|
||||
|
||||
// Set shared cooldown (success — short)
|
||||
setItemCooldown(itemId, true);
|
||||
|
||||
// Track daily mission progress
|
||||
// 'interact' is always tracked, plus the specific action if it maps to a daily mission
|
||||
const dailyActions: DailyMissionAction[] = ['interact'];
|
||||
if (action === 'feed') dailyActions.push('feed');
|
||||
if (action === 'clean') dailyActions.push('clean');
|
||||
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
onError: (error: Error, { itemId }) => {
|
||||
toast({
|
||||
title: 'Failed to use item',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
// Set shared cooldown (failure — longer)
|
||||
setItemCooldown(itemId, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* useItemCooldown — React hook for per-item cooldown state.
|
||||
*
|
||||
* Subscribes to the shared item-cooldown module so that components
|
||||
* automatically re-render when any item's cooldown starts or expires.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { isOnCooldown } = useItemCooldown();
|
||||
* <Button disabled={isOnCooldown(item.id)}>Use</Button>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
import { isItemOnCooldown, subscribeCooldowns } from '../lib/item-cooldown';
|
||||
|
||||
/** Monotonically increasing snapshot counter bumped on every cooldown change. */
|
||||
let snapshotVersion = 0;
|
||||
|
||||
/** Called by subscribeCooldowns — bumps the version so useSyncExternalStore re-renders. */
|
||||
function bumpVersion(): void {
|
||||
snapshotVersion++;
|
||||
}
|
||||
|
||||
// Wire the bump into the cooldown module (idempotent — Set prevents duplicates)
|
||||
subscribeCooldowns(bumpVersion);
|
||||
|
||||
function getSnapshot(): number {
|
||||
return snapshotVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that re-renders the calling component whenever any item's cooldown
|
||||
* starts or ends. Returns a stable `isOnCooldown` checker.
|
||||
*/
|
||||
export function useItemCooldown() {
|
||||
// Subscribe to cooldown changes — triggers re-render via snapshot bump
|
||||
useSyncExternalStore(subscribeCooldowns, getSnapshot);
|
||||
|
||||
const isOnCooldown = useCallback((itemId: string): boolean => {
|
||||
return isItemOnCooldown(itemId);
|
||||
}, []);
|
||||
|
||||
return { isOnCooldown };
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export {
|
||||
clampStat,
|
||||
applyStat,
|
||||
applyItemEffects,
|
||||
filterInventoryByAction,
|
||||
getItemsForAction,
|
||||
decrementStorageItem,
|
||||
canUseAction,
|
||||
canUseDirectAction,
|
||||
|
||||
@@ -12,8 +12,8 @@ import { getShopItemById, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop
|
||||
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
|
||||
|
||||
/**
|
||||
* Direct actions that don't use items.
|
||||
* These actions affect stats directly without selecting a shop item.
|
||||
* Direct actions that don't use items
|
||||
* These actions affect stats directly without using shop items.
|
||||
*/
|
||||
export type DirectAction = 'play_music' | 'sing';
|
||||
|
||||
@@ -273,11 +273,10 @@ export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boole
|
||||
// ─── Item Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolved catalog item with shop metadata
|
||||
* Resolved catalog item with shop metadata.
|
||||
*/
|
||||
export interface ResolvedInventoryItem {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
type: ShopItemCategory;
|
||||
@@ -285,7 +284,7 @@ export interface ResolvedInventoryItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for filtering catalog items by action
|
||||
* Options for filtering catalog items by action.
|
||||
*/
|
||||
export interface FilterInventoryOptions {
|
||||
/** Companion stage - used to filter items by egg-compatible effects */
|
||||
@@ -294,7 +293,7 @@ export interface FilterInventoryOptions {
|
||||
|
||||
/**
|
||||
* Get all available items for an action type from the shop catalog.
|
||||
* Items are abilities/tools — no inventory ownership is required.
|
||||
* Items are reusable abilities — no ownership is required.
|
||||
*
|
||||
* Filtering rules:
|
||||
* - Only items matching the action's item type are included
|
||||
@@ -303,8 +302,7 @@ export interface FilterInventoryOptions {
|
||||
* - medicine action: only items with health effect
|
||||
* - clean action: only items with hygiene or happiness effect
|
||||
*/
|
||||
export function filterInventoryByAction(
|
||||
_storage: StorageItem[],
|
||||
export function getItemsForAction(
|
||||
action: InventoryAction,
|
||||
options: FilterInventoryOptions = {}
|
||||
): ResolvedInventoryItem[] {
|
||||
@@ -324,16 +322,15 @@ export function filterInventoryByAction(
|
||||
// For eggs, filter items by egg-compatible effects
|
||||
if (isEgg) {
|
||||
if (action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
|
||||
continue; // Skip medicine without health effect
|
||||
continue;
|
||||
}
|
||||
if (action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
|
||||
continue; // Skip hygiene items without hygiene or happiness effect
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
itemId: shopItem.id,
|
||||
quantity: Infinity,
|
||||
name: shopItem.name,
|
||||
icon: shopItem.icon,
|
||||
type: shopItem.type,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Centralized item-use cooldown tracking.
|
||||
*
|
||||
* Provides a single, shared per-item cooldown map used by every item-use
|
||||
* path (BlobbiPage dashboard, companion layer, shop modal, falling items).
|
||||
*
|
||||
* Design:
|
||||
* - Module-level singleton — all hooks share the same map.
|
||||
* - Keyed by item type ID (e.g. "food_apple"), NOT instance IDs.
|
||||
* - Separate durations for success (short) and failure (longer).
|
||||
* - Built-in subscriber system so React components can re-render when
|
||||
* cooldowns start or expire.
|
||||
*/
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Cooldown after a successful item use (ms). */
|
||||
export const ITEM_COOLDOWN_SUCCESS_MS = 400;
|
||||
|
||||
/** Cooldown after a failed item use (ms). */
|
||||
export const ITEM_COOLDOWN_FAILURE_MS = 2000;
|
||||
|
||||
// ─── Singleton State ──────────────────────────────────────────────────────────
|
||||
|
||||
interface CooldownEntry {
|
||||
/** Timestamp (Date.now()) when the cooldown expires */
|
||||
expiresAt: number;
|
||||
/** Timeout handle that fires the expiry notification */
|
||||
timerId: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
/** Module-level cooldown map shared across all hooks. */
|
||||
const cooldowns = new Map<string, CooldownEntry>();
|
||||
|
||||
/** Subscribers notified on every cooldown start/end. */
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
// ─── Internal Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function notify(): void {
|
||||
subscribers.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check whether an item is currently on cooldown.
|
||||
*/
|
||||
export function isItemOnCooldown(itemId: string): boolean {
|
||||
const entry = cooldowns.get(itemId);
|
||||
if (!entry) return false;
|
||||
|
||||
if (Date.now() >= entry.expiresAt) {
|
||||
clearTimeout(entry.timerId);
|
||||
cooldowns.delete(itemId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put an item on cooldown after a use attempt.
|
||||
* Subscribers are notified immediately (cooldown started) and again when
|
||||
* the cooldown expires (so the UI can re-enable the button).
|
||||
*/
|
||||
export function setItemCooldown(itemId: string, success: boolean): void {
|
||||
// Clear any existing cooldown for this item
|
||||
const prev = cooldowns.get(itemId);
|
||||
if (prev) clearTimeout(prev.timerId);
|
||||
|
||||
const ms = success ? ITEM_COOLDOWN_SUCCESS_MS : ITEM_COOLDOWN_FAILURE_MS;
|
||||
|
||||
const timerId = setTimeout(() => {
|
||||
cooldowns.delete(itemId);
|
||||
notify(); // re-render: cooldown ended
|
||||
}, ms);
|
||||
|
||||
cooldowns.set(itemId, { expiresAt: Date.now() + ms, timerId });
|
||||
|
||||
notify(); // re-render: cooldown started
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to cooldown state changes.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
export function subscribeCooldowns(callback: () => void): () => void {
|
||||
subscribers.add(callback);
|
||||
return () => {
|
||||
subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
@@ -40,7 +40,8 @@ export {
|
||||
* 1. Registered function from BlobbiPage (if available) - better cache access
|
||||
* 2. Built-in useBlobbiItemUse hook as fallback - works anywhere
|
||||
*
|
||||
* Uses subscription pattern to only re-render when necessary.
|
||||
* Cooldown is enforced via the shared item-cooldown module, which is
|
||||
* consistent across both the registered and fallback paths.
|
||||
*/
|
||||
export function useBlobbiActions(): BlobbiActionsContextValue {
|
||||
const context = useContext(BlobbiActionsContext);
|
||||
@@ -103,8 +104,7 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
|
||||
isUsingItem,
|
||||
canUseItems,
|
||||
isItemOnCooldown: fallbackItemUse.isItemOnCooldown,
|
||||
clearItemCooldown: fallbackItemUse.clearItemCooldown,
|
||||
}), [useItem, isUsingItem, canUseItems, fallbackItemUse.isItemOnCooldown, fallbackItemUse.clearItemCooldown]);
|
||||
}), [useItem, isUsingItem, canUseItems, fallbackItemUse.isItemOnCooldown]);
|
||||
}
|
||||
|
||||
// ─── Registration Hook ────────────────────────────────────────────────────────
|
||||
@@ -187,5 +187,3 @@ export function useBlobbiActionsRegistration(
|
||||
};
|
||||
}, [context]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -47,11 +47,8 @@ export interface BlobbiActionsContextValue {
|
||||
/** Whether items can be used (companion exists and profile loaded) */
|
||||
canUseItems: boolean;
|
||||
|
||||
/** Check if an item is on cooldown (recently attempted) */
|
||||
/** Check if a specific item is on cooldown */
|
||||
isItemOnCooldown: (itemId: string) => boolean;
|
||||
|
||||
/** Clear cooldown for an item */
|
||||
clearItemCooldown: (itemId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -437,32 +437,15 @@ export function HangingItems({
|
||||
// Contact auto-use only triggers when item ENTERS the zone (transitions from outside to inside)
|
||||
const itemsInZoneRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Local item cooldown tracking (fallback if isItemOnCooldown not provided)
|
||||
const localCooldownsRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
// Check if an item is on cooldown (uses prop if available, else local)
|
||||
const checkItemCooldown = useCallback((itemId: string): boolean => {
|
||||
// Check if an item is on cooldown.
|
||||
// Uses the parent-provided isItemOnCooldown (shared module) with the item TYPE id.
|
||||
const checkItemCooldown = useCallback((item: CompanionItem): boolean => {
|
||||
if (isItemOnCooldown) {
|
||||
return isItemOnCooldown(itemId);
|
||||
return isItemOnCooldown(item.id);
|
||||
}
|
||||
// Local fallback cooldown check
|
||||
const expiresAt = localCooldownsRef.current.get(itemId);
|
||||
if (!expiresAt) return false;
|
||||
if (Date.now() >= expiresAt) {
|
||||
localCooldownsRef.current.delete(itemId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return false;
|
||||
}, [isItemOnCooldown]);
|
||||
|
||||
// Set local cooldown for an item
|
||||
const setLocalCooldown = useCallback((itemId: string, success: boolean) => {
|
||||
const cooldownMs = success
|
||||
? HANGING_CONFIG.successUseCooldown
|
||||
: HANGING_CONFIG.failedUseCooldown;
|
||||
localCooldownsRef.current.set(itemId, Date.now() + cooldownMs);
|
||||
}, []);
|
||||
|
||||
// Ref for onItemLanded callback
|
||||
const onItemLandedRef = useRef(onItemLanded);
|
||||
onItemLandedRef.current = onItemLanded;
|
||||
@@ -624,8 +607,8 @@ export function HangingItems({
|
||||
* @param source - How the item was used
|
||||
*/
|
||||
const attemptUseItem = useCallback(async (instanceId: string, item: CompanionItem, source: 'contact' | 'click' | 'drag-drop') => {
|
||||
// Check cooldown first (prevents retry spam) - use instanceId for cooldown tracking
|
||||
if (checkItemCooldown(instanceId)) {
|
||||
// Check shared cooldown by item type ID (prevents retry spam)
|
||||
if (checkItemCooldown(item)) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`[HangingItems] Item on cooldown, skipping:`, item.name, instanceId);
|
||||
}
|
||||
@@ -644,8 +627,6 @@ export function HangingItems({
|
||||
itemsBeingUsedRef.current = new Set(itemsBeingUsedRef.current).add(instanceId);
|
||||
forceUpdate(c => c + 1); // Trigger re-render for visual feedback
|
||||
|
||||
let success = false;
|
||||
|
||||
try {
|
||||
// If onItemUse is provided, use the async flow
|
||||
const onItemUseFn = onItemUseRef.current;
|
||||
@@ -656,7 +637,6 @@ export function HangingItems({
|
||||
const result = await onItemUseFn(item);
|
||||
|
||||
if (result.success) {
|
||||
success = true;
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`[HangingItems] Item used successfully:`, item.name, instanceId);
|
||||
}
|
||||
@@ -685,7 +665,6 @@ export function HangingItems({
|
||||
}
|
||||
} else {
|
||||
// Legacy behavior: call onItemCollected and remove immediately
|
||||
success = true;
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`[HangingItems] Item collected (legacy):`, item.name, instanceId);
|
||||
}
|
||||
@@ -712,11 +691,9 @@ export function HangingItems({
|
||||
newSet.delete(instanceId);
|
||||
itemsBeingUsedRef.current = newSet;
|
||||
forceUpdate(c => c + 1);
|
||||
|
||||
// Set cooldown (longer on failure to prevent retry spam)
|
||||
setLocalCooldown(instanceId, success);
|
||||
// Cooldown is set by the mutation hooks (onSuccess/onError) via the shared module
|
||||
}
|
||||
}, [checkItemCooldown, setLocalCooldown]); // Minimal dependencies - rest uses refs
|
||||
}, [checkItemCooldown]); // Minimal dependencies - rest uses refs
|
||||
|
||||
// Contact detection with Blobbi (for auto-use)
|
||||
//
|
||||
|
||||
@@ -63,7 +63,7 @@ export function getItemCategoryForAction(actionId: CompanionMenuAction): ShopIte
|
||||
|
||||
/**
|
||||
* Normalized item representation for the companion UI.
|
||||
* This is a simplified view of shop catalog items optimized for rendering.
|
||||
* A simplified view of shop catalog items optimized for rendering.
|
||||
*/
|
||||
export interface CompanionItem {
|
||||
/** Unique item ID (matches shop item ID) */
|
||||
@@ -74,8 +74,6 @@ export interface CompanionItem {
|
||||
emoji: string;
|
||||
/** Item category */
|
||||
category: ShopItemCategory;
|
||||
/** Availability (always Infinity — items are reusable abilities) */
|
||||
quantity: number;
|
||||
/** Item effects when used */
|
||||
effect?: ItemEffect;
|
||||
}
|
||||
|
||||
@@ -8,16 +8,16 @@
|
||||
* - Fetches companion and profile data if not provided
|
||||
* - Uses the same item-use logic as BlobbiPage (useBlobbiUseInventoryItem)
|
||||
* - Works as a standalone hook or can be passed cached data
|
||||
* - Provides retry protection and cooldown
|
||||
* - Uses the shared item-cooldown module for per-item cooldown
|
||||
*
|
||||
* Architecture:
|
||||
* - BlobbiCompanionLayer uses this hook directly as a fallback when
|
||||
* BlobbiPage is not mounted
|
||||
* - BlobbiPage registers its own item-use function (which has better cache access)
|
||||
* - Both use the same underlying mutation logic
|
||||
* - Both use the same underlying mutation logic and shared cooldown
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
@@ -54,17 +54,13 @@ import type { DailyMissionAction } from '@/blobbi/actions/lib/daily-missions';
|
||||
import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useEvolveTasks';
|
||||
import {
|
||||
isItemOnCooldown,
|
||||
setItemCooldown,
|
||||
} from '@/blobbi/actions/lib/item-cooldown';
|
||||
|
||||
import type { UseItemFunction } from './BlobbiActionsContextDef';
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Cooldown time after a failed item use attempt (ms) */
|
||||
const ITEM_USE_COOLDOWN_MS = 3000;
|
||||
|
||||
/** Cooldown time after a successful item use (ms) - shorter to allow quick successive uses */
|
||||
const ITEM_USE_SUCCESS_COOLDOWN_MS = 500;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseBlobbiItemUseOptions {
|
||||
@@ -80,23 +76,14 @@ export interface UseBlobbiItemUseOptions {
|
||||
}
|
||||
|
||||
export interface UseBlobbiItemUseResult {
|
||||
/** The item use function - same signature as UseItemFunction */
|
||||
/** The item use function — same signature as UseItemFunction */
|
||||
useItem: UseItemFunction;
|
||||
/** Whether item use is available (companion and profile loaded) */
|
||||
canUseItems: boolean;
|
||||
/** Whether an item use is currently in progress */
|
||||
isUsingItem: boolean;
|
||||
/** Check if an item is on cooldown (recently attempted) */
|
||||
/** Check if an item is on cooldown (delegates to shared module) */
|
||||
isItemOnCooldown: (itemId: string) => boolean;
|
||||
/** Clear cooldown for an item (e.g., after it's removed) */
|
||||
clearItemCooldown: (itemId: string) => void;
|
||||
}
|
||||
|
||||
interface ItemCooldownEntry {
|
||||
/** Timestamp when the cooldown expires */
|
||||
expiresAt: number;
|
||||
/** Whether the last attempt succeeded */
|
||||
wasSuccess: boolean;
|
||||
}
|
||||
|
||||
// ─── Hook Implementation ──────────────────────────────────────────────────────
|
||||
@@ -104,16 +91,8 @@ interface ItemCooldownEntry {
|
||||
/**
|
||||
* Shared Blobbi item-use hook that works anywhere.
|
||||
*
|
||||
* This is the "real" item-use logic extracted to be usable from:
|
||||
* - BlobbiCompanionLayer (floating companion)
|
||||
* - BlobbiPage (main dashboard)
|
||||
* - Any other location
|
||||
*
|
||||
* Features:
|
||||
* - Fetches companion/profile data if not provided
|
||||
* - Identical item-use logic to useBlobbiUseInventoryItem
|
||||
* - Built-in per-item cooldown/retry protection
|
||||
* - Works as a direct hook or registered in context
|
||||
* Uses the centralized item-cooldown module so that cooldown is
|
||||
* consistent regardless of which UI path triggers the use.
|
||||
*/
|
||||
export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlobbiItemUseResult {
|
||||
const { nostr } = useNostr();
|
||||
@@ -125,42 +104,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
const { profile: fetchedProfile } = useBlobbonautProfile();
|
||||
const profile = options.profile ?? fetchedProfile;
|
||||
|
||||
// Per-item cooldown tracking (ref to avoid re-renders)
|
||||
const itemCooldowns = useRef<Map<string, ItemCooldownEntry>>(new Map());
|
||||
|
||||
// Check if an item is on cooldown
|
||||
const isItemOnCooldown = useCallback((itemId: string): boolean => {
|
||||
const entry = itemCooldowns.current.get(itemId);
|
||||
if (!entry) return false;
|
||||
|
||||
const now = Date.now();
|
||||
if (now >= entry.expiresAt) {
|
||||
// Cooldown expired, remove it
|
||||
itemCooldowns.current.delete(itemId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// Clear cooldown for an item
|
||||
const clearItemCooldown = useCallback((itemId: string): void => {
|
||||
itemCooldowns.current.delete(itemId);
|
||||
}, []);
|
||||
|
||||
// Set cooldown for an item
|
||||
const setItemCooldown = useCallback((itemId: string, success: boolean): void => {
|
||||
const cooldownMs = success ? ITEM_USE_SUCCESS_COOLDOWN_MS : ITEM_USE_COOLDOWN_MS;
|
||||
itemCooldowns.current.set(itemId, {
|
||||
expiresAt: Date.now() + cooldownMs,
|
||||
wasSuccess: success,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Fetch current companion based on profile's currentCompanion
|
||||
// This is fetched on-demand when needed, not kept in state
|
||||
const fetchCurrentCompanion = useCallback(async (): Promise<BlobbiCompanion | null> => {
|
||||
// If companion was provided via options, use that
|
||||
if (options.companion !== undefined) {
|
||||
return options.companion ?? null;
|
||||
}
|
||||
@@ -184,51 +129,29 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
return parseBlobbiEvent(validEvents[0]) ?? null;
|
||||
}, [nostr, user?.pubkey, profile?.currentCompanion, options.companion]);
|
||||
|
||||
// Update companion in query cache - optimistic update for immediate UI refresh
|
||||
// Update companion in query cache
|
||||
const updateCompanionInCache = useCallback((event: NostrEvent) => {
|
||||
if (!user?.pubkey || !profile?.currentCompanion) return;
|
||||
|
||||
// Parse the new event to get the updated companion
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (!parsed) {
|
||||
// Fallback to invalidation if parsing fails
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['blobbi-collection', user.pubkey]
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistically update the blobbi-collection cache
|
||||
// This ensures the companion layer sees the update immediately
|
||||
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] } | undefined>(
|
||||
// Use partial key match - React Query will find any matching query
|
||||
['blobbi-collection', user.pubkey],
|
||||
(prev) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
// 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,
|
||||
};
|
||||
const newCompanionsByD = { ...prev.companionsByD, [parsed.d]: parsed };
|
||||
return { companionsByD: newCompanionsByD, companions: Object.values(newCompanionsByD) };
|
||||
},
|
||||
);
|
||||
|
||||
// Also invalidate to trigger background refetch (ensures consistency)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['blobbi-collection', user.pubkey]
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
|
||||
}, [queryClient, user?.pubkey, profile?.currentCompanion]);
|
||||
|
||||
// Core mutation for using items (always uses once)
|
||||
// Core mutation for using items (always single-use)
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
itemId,
|
||||
@@ -237,6 +160,11 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
itemId: string;
|
||||
action: InventoryAction;
|
||||
}): Promise<{ statsChanged: Record<string, number> }> => {
|
||||
// ─── Cooldown guard (shared across all UIs) ───
|
||||
if (isItemOnCooldown(itemId)) {
|
||||
throw new Error('Please wait before using this item again');
|
||||
}
|
||||
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to use items');
|
||||
@@ -246,38 +174,30 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
// Fetch fresh companion data
|
||||
const companion = await fetchCurrentCompanion();
|
||||
|
||||
if (!companion) {
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
// Check stage restrictions
|
||||
if (!canUseAction(companion, action)) {
|
||||
const message = getStageRestrictionMessage(companion, action);
|
||||
throw new Error(message ?? 'This companion cannot use this item');
|
||||
}
|
||||
|
||||
// Validate item exists in shop catalog
|
||||
const shopItem = getShopItemById(itemId);
|
||||
if (!shopItem) {
|
||||
throw new Error('Item not found in catalog');
|
||||
}
|
||||
|
||||
// Validate item can be used by this companion's stage
|
||||
// This catches egg-only items (like Shell Repair Kit) being used by baby/adult companions
|
||||
const itemUsability = canUseItemForStage(itemId, companion.stage);
|
||||
if (!itemUsability.canUse) {
|
||||
throw new Error(itemUsability.reason ?? 'This item cannot be used by this companion');
|
||||
}
|
||||
|
||||
// Validate item has effects
|
||||
if (!shopItem.effect) {
|
||||
throw new Error('This item has no effect');
|
||||
}
|
||||
|
||||
// For eggs, validate that items have applicable effects
|
||||
const isEgg = companion.stage === 'egg';
|
||||
if (isEgg && action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
|
||||
throw new Error('This medicine has no effect on eggs');
|
||||
@@ -295,8 +215,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
lastDecayAt: companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// Start with decayed stats as the base
|
||||
const statsAfterDecay = decayResult.stats;
|
||||
|
||||
// ─── Apply Item Effects (single use) ───
|
||||
@@ -306,10 +224,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
|
||||
if (isEggCompanion && action === 'medicine') {
|
||||
const currentHealth = applyStat(statsAfterDecay.health ?? 0, shopItem.effect.health ?? 0);
|
||||
|
||||
statsUpdate.health = currentHealth.toString();
|
||||
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
|
||||
|
||||
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
|
||||
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
|
||||
statsUpdate.hunger = '100';
|
||||
@@ -317,43 +233,30 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
} else if (isEggCompanion && action === 'clean') {
|
||||
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
|
||||
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
|
||||
|
||||
statsUpdate.hygiene = currentHygiene.toString();
|
||||
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
|
||||
|
||||
statsUpdate.happiness = currentHappiness.toString();
|
||||
const totalHappinessChange = currentHappiness - (statsAfterDecay.happiness ?? 0);
|
||||
if (totalHappinessChange !== 0) {
|
||||
statsChanged.happiness = totalHappinessChange;
|
||||
}
|
||||
|
||||
const happinessChange = currentHappiness - (statsAfterDecay.happiness ?? 0);
|
||||
if (happinessChange !== 0) statsChanged.happiness = happinessChange;
|
||||
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else {
|
||||
// Normal stats application for baby/adult — apply once
|
||||
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
|
||||
|
||||
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
|
||||
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
|
||||
|
||||
statsUpdate.happiness = clampStat(currentStats.happiness).toString();
|
||||
statsChanged.happiness = (currentStats.happiness ?? 0) - (statsAfterDecay.happiness ?? 0);
|
||||
|
||||
statsUpdate.energy = clampStat(currentStats.energy).toString();
|
||||
statsChanged.energy = (currentStats.energy ?? 0) - (statsAfterDecay.energy ?? 0);
|
||||
|
||||
statsUpdate.hygiene = clampStat(currentStats.hygiene).toString();
|
||||
statsChanged.hygiene = (currentStats.hygiene ?? 0) - (statsAfterDecay.hygiene ?? 0);
|
||||
|
||||
statsUpdate.health = clampStat(currentStats.health).toString();
|
||||
statsChanged.health = (currentStats.health ?? 0) - (statsAfterDecay.health ?? 0);
|
||||
}
|
||||
|
||||
// ─── Update Blobbi State Event (kind 31124) ───
|
||||
const nowStr = now.toString();
|
||||
|
||||
// Handle interaction counter for tasks
|
||||
const companionState = companion.state;
|
||||
let updatedTags = companion.allTags;
|
||||
if (companionState === 'incubating') {
|
||||
@@ -362,7 +265,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
updatedTags = incrementInteractionTaskTags(companion.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
|
||||
}
|
||||
|
||||
// Get streak updates (will only update if needed based on day)
|
||||
const streakUpdates = getStreakTagUpdates(companion) ?? {};
|
||||
|
||||
const blobbiTags = updateBlobbiTags(updatedTags, {
|
||||
@@ -379,9 +281,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
});
|
||||
|
||||
updateCompanionInCache(blobbiEvent);
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
// Items are free to use — no storage decrement needed.
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
|
||||
|
||||
return { statsChanged };
|
||||
@@ -395,14 +294,14 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
description: `Used ${shopItem?.name ?? 'item'} on your Blobbi.`,
|
||||
});
|
||||
|
||||
// Set shared cooldown (success — short)
|
||||
setItemCooldown(itemId, true);
|
||||
|
||||
// Track daily mission progress
|
||||
const dailyActions: DailyMissionAction[] = ['interact'];
|
||||
if (action === 'feed') dailyActions.push('feed');
|
||||
if (action === 'clean') dailyActions.push('clean');
|
||||
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
|
||||
|
||||
// Set success cooldown (short)
|
||||
setItemCooldown(itemId, true);
|
||||
},
|
||||
onError: (error: Error, { itemId }) => {
|
||||
toast({
|
||||
@@ -411,14 +310,14 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
// Set failure cooldown (longer)
|
||||
// Set shared cooldown (failure — longer)
|
||||
setItemCooldown(itemId, false);
|
||||
},
|
||||
});
|
||||
|
||||
// Wrapper function that matches UseItemFunction signature and includes cooldown check
|
||||
// Wrapper function that matches UseItemFunction signature
|
||||
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
|
||||
// Check cooldown first
|
||||
// Check shared cooldown first
|
||||
if (isItemOnCooldown(itemId)) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[useBlobbiItemUse] Item on cooldown, skipping:', itemId);
|
||||
@@ -441,7 +340,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}, [mutation, isItemOnCooldown]);
|
||||
}, [mutation]);
|
||||
|
||||
// Determine if items can be used
|
||||
const canUseItems = useMemo(() => {
|
||||
@@ -453,6 +352,5 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
canUseItems,
|
||||
isUsingItem: mutation.isPending,
|
||||
isItemOnCooldown,
|
||||
clearItemCooldown,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,7 +112,6 @@ function resolveItemsForAction(
|
||||
name: shopItem.name,
|
||||
emoji: shopItem.icon,
|
||||
category: shopItem.type,
|
||||
quantity: Infinity,
|
||||
effect: shopItem.effect,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ export function BlobbiInventoryModal({
|
||||
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 flex items-center justify-center shrink-0">
|
||||
<Package className="size-4 sm:size-5 text-primary" />
|
||||
</div>
|
||||
<DialogTitle className="text-xl sm:text-2xl">Inventory</DialogTitle>
|
||||
<DialogTitle className="text-xl sm:text-2xl">Items</DialogTitle>
|
||||
</div>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
|
||||
<X className="size-5" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ShoppingBag, Package, Loader2, X } from 'lucide-react';
|
||||
import { ShoppingBag, Package, Loader2, X, Clock } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,6 +19,7 @@ import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobb
|
||||
import { getLiveShopItems } from '../lib/blobbi-shop-items';
|
||||
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
import { useItemCooldown } from '@/blobbi/actions/hooks/useItemCooldown';
|
||||
import { cn, formatCompactNumber } from '@/lib/utils';
|
||||
|
||||
type TopTab = 'items' | 'shop';
|
||||
@@ -271,6 +272,8 @@ interface ItemsGridProps {
|
||||
}
|
||||
|
||||
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _onGoToShop }: ItemsGridProps) {
|
||||
const { isOnCooldown } = useItemCooldown();
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
|
||||
@@ -289,6 +292,8 @@ function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _on
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{items.map(item => {
|
||||
const isThisUsing = isUsingItem && usingItemId === item.itemId;
|
||||
const isCoolingDown = isOnCooldown(item.itemId);
|
||||
const isDisabled = isUsingItem || isCoolingDown;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -312,10 +317,12 @@ function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _on
|
||||
variant="outline"
|
||||
className="w-full h-7 text-xs"
|
||||
onClick={() => onUseItem(item)}
|
||||
disabled={isUsingItem}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{isThisUsing ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : isCoolingDown ? (
|
||||
<Clock className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
'Use'
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, AlertTriangle, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, HeartHandshake, Package, Target, Droplets, Heart, Zap } from 'lucide-react';
|
||||
import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, AlertTriangle, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, HeartHandshake, Package, Target, Droplets, Heart, Zap, Clock } from 'lucide-react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState';
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
// DailyMissionsPanel no longer used — daily missions rendered inline in MissionsTabContent
|
||||
import { BlobbiOnboardingFlow } from '@/blobbi/onboarding';
|
||||
import { useBlobbiActionsRegistration, type UseItemFunction } from '@/blobbi/companion/interaction';
|
||||
import { useItemCooldown } from '@/blobbi/actions/hooks/useItemCooldown';
|
||||
import { BlobbiDevEditor, useBlobbiDevUpdate, type BlobbiDevUpdates, BlobbiEmotionPanel, useEffectiveEmotion, isLocalhostDev } from '@/blobbi/dev';
|
||||
import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction';
|
||||
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
@@ -1972,20 +1973,24 @@ function ItemsTabContent({
|
||||
isUsingItem,
|
||||
usingItemId,
|
||||
}: ItemsTabContentProps) {
|
||||
const { isOnCooldown } = useItemCooldown();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 sm:grid-cols-5 gap-0.5">
|
||||
{allShopItems.filter(i => i.status !== 'disabled').map((item) => {
|
||||
const isThisUsing = isUsingItem && usingItemId === item.id;
|
||||
const isCoolingDown = isOnCooldown(item.id);
|
||||
const isDisabled = isUsingItem || isCoolingDown;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onUseItem(item.id)}
|
||||
disabled={isUsingItem}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
'group relative flex flex-col items-center justify-center gap-0.5 py-3 rounded-2xl transition-all duration-200',
|
||||
'hover:bg-accent/50 hover:-translate-y-0.5 active:scale-[0.93] active:translate-y-0',
|
||||
isThisUsing && 'bg-accent/40 -translate-y-0.5',
|
||||
isUsingItem && !isThisUsing && 'opacity-40 pointer-events-none',
|
||||
isDisabled && !isThisUsing && 'opacity-40 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{/* Stat category indicator — top-right */}
|
||||
@@ -1995,6 +2000,7 @@ function ItemsTabContent({
|
||||
<span className="text-4xl leading-none transition-transform duration-200 group-hover:scale-110">{item.icon}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-medium truncate w-full text-center px-1">{item.name}</span>
|
||||
{isThisUsing && <Loader2 className="size-3 animate-spin text-primary absolute bottom-1" />}
|
||||
{isCoolingDown && !isThisUsing && <Clock className="size-3 text-muted-foreground absolute bottom-1" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user