Unify item-use cooldown with shared module and simplify item flows

Introduce a centralized item-cooldown module (item-cooldown.ts) that
provides a single per-item cooldown map shared across every item-use
path: BlobbiPage dashboard, companion floating UI, shop modal, and
the falling-items system.

Previously, cooldown was only enforced in the companion layer's
fallback hook (useBlobbiItemUse) and HangingItems had its own local
fallback map. The primary dashboard mutation (useBlobbiUseInventoryItem)
had zero cooldown — only the isPending mutex prevented spam. Now all
paths check and set cooldown via the same shared singleton.

Also included in this commit:
- Remove quantity selectors and confirmation dialogs from modals
- Replace multi-quantity stat loops with single-use application
- Drop quantity parameter from UseItemFunction and all call sites
- Remove dead clearItemCooldown code from context chain
- Add isItemOnCooldown prop to BlobbiActionInventoryModal for
  per-item disabled state on Use buttons
- Remove HangingItems local cooldown fallback (delegates to parent)
- Fix HangingItems cooldown key mismatch (now uses item type ID)
This commit is contained in:
filemon
2026-04-05 21:54:59 -03:00
parent ac8bffba23
commit 377a6cbb97
12 changed files with 333 additions and 1040 deletions
@@ -1,19 +1,16 @@
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
import { useMemo, useState } from 'react';
import { Loader2, Minus, Plus, X } from 'lucide-react';
import { useMemo } from 'react';
import { Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { cn } from '@/lib/utils';
@@ -37,11 +34,13 @@ interface BlobbiActionInventoryModalProps {
action: InventoryAction;
companion: BlobbiCompanion;
profile: BlobbonautProfile | null;
/** Called when user confirms using item(s). Now accepts quantity. */
onUseItem: (itemId: string, quantity: number) => void;
/** Called when user taps Use on an item. Always uses once. */
onUseItem: (itemId: string) => void;
onOpenShop: () => void;
isUsingItem: boolean;
usingItemId: string | null;
/** Check whether a specific item is on cooldown */
isItemOnCooldown?: (itemId: string) => boolean;
}
export function BlobbiActionInventoryModal({
@@ -54,16 +53,11 @@ export function BlobbiActionInventoryModal({
onOpenShop: _onOpenShop,
isUsingItem,
usingItemId,
isItemOnCooldown,
}: BlobbiActionInventoryModalProps) {
const actionMeta = ACTION_METADATA[action];
// State for confirmation dialog
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
const [quantity, setQuantity] = useState(1);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
// 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 });
}, [action, companion.stage]);
@@ -74,41 +68,10 @@ export function BlobbiActionInventoryModal({
const isEmpty = availableItems.length === 0;
const handleSelectItem = (item: ResolvedInventoryItem) => {
const handleUseItem = (item: ResolvedInventoryItem) => {
if (isUsingItem) return;
setSelectedItem(item);
setQuantity(1);
setShowConfirmDialog(true);
};
const handleConfirmUse = () => {
if (!selectedItem || isUsingItem) return;
onUseItem(selectedItem.itemId, quantity);
// Reset after starting use
setShowConfirmDialog(false);
setSelectedItem(null);
setQuantity(1);
};
const handleCloseConfirmDialog = (isOpen: boolean) => {
if (!isOpen) {
setShowConfirmDialog(false);
setSelectedItem(null);
setQuantity(1);
}
};
// Quantity controls - items are unlimited abilities, cap at a reasonable max
const maxQuantity = 99;
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 1) {
setQuantity(1);
} else {
setQuantity(Math.min(value, maxQuantity));
}
if (isItemOnCooldown?.(item.itemId)) return;
onUseItem(item.itemId);
};
return (
@@ -166,78 +129,64 @@ 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={() => handleSelectItem(item)}
isUsing={isUsingItem && usingItemId === item.itemId}
disabled={isUsingItem}
/>
))}
{availableItems.map((item) => {
const isCoolingDown = isItemOnCooldown?.(item.itemId) ?? false;
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>
</DialogContent>
{/* Confirmation Dialog with Quantity Selector */}
{selectedItem && (
<BlobbiUseItemConfirmDialog
open={showConfirmDialog}
onOpenChange={handleCloseConfirmDialog}
item={selectedItem}
companion={companion}
action={action}
quantity={quantity}
maxQuantity={maxQuantity}
onIncrease={handleIncrease}
onDecrease={handleDecrease}
onQuantityChange={handleQuantityInput}
onConfirm={handleConfirmUse}
isUsing={isUsingItem}
/>
)}
</Dialog>
);
}
// ─── 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) {
// For eggs using medicine, show health preview
// Eggs use the 3-stat model: health, hygiene, happiness
return {
normalStatChanges: [],
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
};
}
if (isEgg && isClean) {
// For eggs using hygiene items, show hygiene (and possibly happiness) preview
return {
normalStatChanges: [],
eggStatChanges: previewCleanForEgg(
@@ -246,7 +195,6 @@ function BlobbiInventoryUseRow({
),
};
}
// Normal stats preview
return {
normalStatChanges: previewStatChanges(companion.stats, item.effect),
eggStatChanges: [] as EggStatPreview[],
@@ -277,42 +225,20 @@ function BlobbiInventoryUseRow({
<div className="hidden sm:block">
{hasChanges && (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{/* Normal stat changes */}
{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>
))}
{/* Egg stat changes (health for medicine) */}
{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>
@@ -325,7 +251,7 @@ function BlobbiInventoryUseRow({
size="sm"
onClick={onUse}
disabled={disabled}
className="shrink-0"
className={cn('shrink-0', isCoolingDown && 'opacity-50')}
>
{isUsing ? (
<Loader2 className="size-4 animate-spin" />
@@ -338,42 +264,20 @@ function BlobbiInventoryUseRow({
{/* Effect Preview - shown below on mobile */}
{hasChanges && (
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
{/* Normal stat changes */}
{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>
))}
{/* Egg stat changes (health for medicine) */}
{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>
@@ -381,216 +285,3 @@ function BlobbiInventoryUseRow({
</div>
);
}
// ─── Use Item Confirmation Dialog ─────────────────────────────────────────────
interface BlobbiUseItemConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
action: InventoryAction;
quantity: number;
maxQuantity: number;
onIncrease: () => void;
onDecrease: () => void;
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onConfirm: () => void;
isUsing: boolean;
}
function BlobbiUseItemConfirmDialog({
open,
onOpenChange,
item,
companion,
action,
quantity,
maxQuantity,
onIncrease,
onDecrease,
onQuantityChange,
onConfirm,
isUsing,
}: BlobbiUseItemConfirmDialogProps) {
const actionMeta = ACTION_METADATA[action];
const isEgg = companion.stage === 'egg';
const isMedicine = action === 'medicine';
const isClean = action === 'clean';
// Preview stat changes for the selected quantity
const statPreview = useMemo(() => {
if (!item.effect) return { normalChanges: [], eggChanges: [] };
if (isEgg && isMedicine) {
// Calculate health change for N items
const healthDelta = item.effect.health ?? 0;
let currentHealth = companion.stats.health ?? 0;
for (let i = 0; i < quantity; i++) {
currentHealth = Math.max(0, Math.min(100, currentHealth + healthDelta));
}
const totalDelta = currentHealth - (companion.stats.health ?? 0);
return {
normalChanges: [],
eggChanges: totalDelta !== 0 ? [{ stat: 'health' as const, delta: totalDelta }] : [],
};
}
if (isEgg && isClean) {
// Calculate hygiene and happiness changes for N items
const hygieneDelta = item.effect.hygiene ?? 0;
const happinessDelta = item.effect.happiness ?? 0;
let currentHygiene = companion.stats.hygiene ?? 0;
let currentHappiness = companion.stats.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
currentHygiene = Math.max(0, Math.min(100, currentHygiene + hygieneDelta));
currentHappiness = Math.max(0, Math.min(100, currentHappiness + happinessDelta));
}
const changes: Array<{ stat: 'health' | 'hygiene' | 'happiness'; delta: number }> = [];
const totalHygieneDelta = currentHygiene - (companion.stats.hygiene ?? 0);
const totalHappinessDelta = currentHappiness - (companion.stats.happiness ?? 0);
if (totalHygieneDelta !== 0) changes.push({ stat: 'hygiene', delta: totalHygieneDelta });
if (totalHappinessDelta !== 0) changes.push({ stat: 'happiness', delta: totalHappinessDelta });
return { normalChanges: [], eggChanges: changes };
}
// Normal stats preview - simulate N applications
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
const currentStats = { ...companion.stats };
for (let i = 0; i < quantity; i++) {
for (const stat of statKeys) {
const delta = item.effect[stat];
if (delta !== undefined) {
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
}
}
}
const changes: Array<{ stat: string; delta: number }> = [];
for (const stat of statKeys) {
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
if (delta !== 0) {
changes.push({ stat, delta });
}
}
return { normalChanges: changes, eggChanges: [] };
}, [item.effect, companion.stats, quantity, isEgg, isMedicine, isClean]);
const hasChanges = statPreview.normalChanges.length > 0 || statPreview.eggChanges.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
<DialogHeader>
<DialogTitle>{actionMeta.label}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Item Preview */}
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{item.name}</h3>
</div>
</div>
{/* Quantity Selector */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Quantity</label>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={onDecrease}
disabled={quantity <= 1 || isUsing}
>
<Minus className="size-4" />
</Button>
<Input
type="number"
min="1"
max={maxQuantity}
value={quantity}
onChange={onQuantityChange}
disabled={isUsing}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={onIncrease}
disabled={quantity >= maxQuantity || isUsing}
>
<Plus className="size-4" />
</Button>
</div>
</div>
{/* Effects Summary */}
{hasChanges && (
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
<h4 className="text-sm font-medium mb-2">
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
</h4>
<div className="flex flex-wrap gap-2">
{statPreview.normalChanges.map(({ stat, delta }) => (
<Badge
key={stat}
variant="secondary"
className={cn(
'text-xs',
delta > 0
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
)}
>
{delta > 0 ? '+' : ''}{delta} {stat}
</Badge>
))}
{statPreview.eggChanges.map(({ stat, delta }) => (
<Badge
key={stat}
variant="secondary"
className={cn(
'text-xs',
delta > 0
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
)}
>
{delta > 0 ? '+' : ''}{delta} {stat}
</Badge>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isUsing}
>
Cancel
</Button>
<Button
onClick={onConfirm}
disabled={isUsing}
className="min-w-24"
>
{isUsing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Using...
</>
) : (
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -6,7 +6,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
@@ -29,27 +29,24 @@ 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 inventory item
* Request payload for using an item on a Blobbi companion.
*/
export interface UseItemRequest {
itemId: string;
action: InventoryAction;
/** Number of items to use (defaults to 1) */
quantity?: number;
}
/**
* Result of using an inventory item
* Result of using an item on a Blobbi companion.
*/
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;
@@ -67,9 +64,9 @@ export interface UseBlobbiUseInventoryItemParams {
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration (use instead of profile.allTags) */
/** Latest profile tags after migration */
profileAllTags: string[][];
/** Latest profile storage after migration (use instead of profile.storage) */
/** Latest profile storage after migration */
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
@@ -82,16 +79,20 @@ export interface UseBlobbiUseInventoryItemParams {
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook to use an inventory item on a Blobbi companion.
*
* Hook to use an item on a Blobbi companion.
*
* Items are reusable abilities sourced from the shop catalog — no
* 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 stage (eggs can't use items)
* 2. Validates the item exists in storage
* 1. Validates the companion and item compatibility
* 2. Checks the shared per-item cooldown
* 3. Ensures canonical format before action
* 4. Applies item effects to Blobbi stats
* 4. Applies accumulated decay, then item effects to Blobbi stats
* 5. Updates Blobbi state (kind 31124)
* 6. Decrements item from profile storage (kind 11125)
* 7. Invalidates relevant queries
*/
export function useBlobbiUseInventoryItem({
companion,
@@ -104,7 +105,12 @@ export function useBlobbiUseInventoryItem({
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ itemId, action, quantity = 1 }: UseItemRequest): Promise<UseItemResult> => {
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');
@@ -118,11 +124,6 @@ export function useBlobbiUseInventoryItem({
throw new Error('Profile not found');
}
// Validate quantity
if (quantity < 1) {
throw new Error('Quantity must be at least 1');
}
// Check stage restrictions for this specific action
if (!canUseAction(companion, action)) {
const message = getStageRestrictionMessage(companion, action);
@@ -156,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,
@@ -172,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;
@@ -183,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;
@@ -197,78 +192,24 @@ export function useBlobbiUseInventoryItem({
}
}
// ─── 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
// ─── Apply Item Effects (single use) ───
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:
// Eggs use the 3-stat model: health, hygiene, happiness
// Medicine with health effect directly affects the egg's health stat
// hunger and energy remain fixed at 100 for eggs
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++;
}
}
const currentHealth = applyStat(statsAfterDecay.health ?? 0, shopItem.effect.health ?? 0);
statsUpdate.health = currentHealth.toString();
// Track total actual change (may be less than healthDelta * quantity due to clamping)
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
// Apply decayed values for other egg stats
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
// hunger and energy stay at 100 for eggs
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else if (isEggCompanion && action === 'clean') {
// Egg clean/hygiene handling:
// Hygiene items affect the egg's hygiene stat
// Some hygiene items also give happiness (e.g., bubble bath)
// hunger and energy remain fixed at 100 for eggs
const hygieneDelta = shopItem.effect.hygiene ?? 0;
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++;
}
}
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);
@@ -279,58 +220,12 @@ export function useBlobbiUseInventoryItem({
statsChanged.happiness = totalHappinessChange;
}
// Apply decayed health
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
// hunger and energy stay at 100 for eggs
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} 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++) {
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++;
}
}
// 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);
@@ -351,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') {
@@ -360,14 +254,10 @@ 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 (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;
// ─── Apply XP Gain ───
const xpGained = calculateInventoryActionXP(action, 1);
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
@@ -387,43 +277,40 @@ 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,
quantity,
effectiveItemCount, // How many items actually changed stats
statsChanged,
xpGained,
newXP,
};
},
onSuccess: ({ itemName, action, quantity, xpGained }) => {
onSuccess: ({ itemName, action, xpGained }, { itemId }) => {
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. ${xpText}`,
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);
},
});
}
+60
View File
@@ -0,0 +1,60 @@
/**
* 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).
* - Pure functions with no React dependency so they can be called from
* mutation callbacks, UI checks, or anywhere else.
*/
// ─── Configuration ────────────────────────────────────────────────────────────
/** Cooldown after a successful item use (ms). Short — lets rapid intentional taps through. */
export const ITEM_COOLDOWN_SUCCESS_MS = 500;
/** Cooldown after a failed item use (ms). Longer — prevents retry spam. */
export const ITEM_COOLDOWN_FAILURE_MS = 3000;
// ─── Singleton Map ────────────────────────────────────────────────────────────
interface CooldownEntry {
/** Timestamp (Date.now()) when the cooldown expires */
expiresAt: number;
}
/** Module-level cooldown map shared across all hooks. */
const cooldowns = new Map<string, CooldownEntry>();
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Check whether an item is currently on cooldown.
* Automatically cleans up expired entries.
*/
export function isItemOnCooldown(itemId: string): boolean {
const entry = cooldowns.get(itemId);
if (!entry) return false;
if (Date.now() >= entry.expiresAt) {
cooldowns.delete(itemId);
return false;
}
return true;
}
/**
* Put an item on cooldown after a use attempt.
*
* @param itemId - The shop catalog item ID
* @param success - Whether the use attempt succeeded
*/
export function setItemCooldown(itemId: string, success: boolean): void {
const ms = success ? ITEM_COOLDOWN_SUCCESS_MS : ITEM_COOLDOWN_FAILURE_MS;
cooldowns.set(itemId, { expiresAt: Date.now() + ms });
}
@@ -161,7 +161,7 @@ export function BlobbiCompanionLayer() {
}
try {
const result = await contextUseItem(item.id, action, 1);
const result = await contextUseItem(item.id, action);
if (result.success) {
if (import.meta.env.DEV) {
@@ -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);
@@ -62,13 +63,13 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
// Create stable useItem function that:
// 1. Uses registered function if available (from BlobbiPage)
// 2. Falls back to built-in hook if no registration
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
// Try registered function first (from BlobbiPage)
if (context?.registerRef.current) {
if (import.meta.env.DEV) {
console.log('[BlobbiActions] Using registered item-use function');
}
return context.registerRef.current(itemId, action, quantity);
return context.registerRef.current(itemId, action);
}
// Check if fallback can handle it
@@ -86,7 +87,7 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
if (import.meta.env.DEV) {
console.log('[BlobbiActions] Using fallback item-use hook');
}
return fallbackItemUse.useItem(itemId, action, quantity);
return fallbackItemUse.useItem(itemId, action);
}, [context, fallbackItemUse]);
// Determine canUseItems: true if registered OR fallback can use
@@ -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 ────────────────────────────────────────────────────────
@@ -134,14 +134,14 @@ export function useBlobbiActionsRegistration(
useItemRef.current = useItemFn;
// Create a stable wrapper that delegates to the ref
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action) => {
if (!useItemRef.current) {
return {
success: false,
error: 'Item use function not available',
};
}
return useItemRef.current(itemId, action, quantity);
return useItemRef.current(itemId, action);
}, []);
// Update refs and notify only when canUseItems actually changes
@@ -187,5 +187,3 @@ export function useBlobbiActionsRegistration(
};
}, [context]);
}
@@ -24,12 +24,11 @@ export interface UseItemResult {
}
/**
* Function signature for using an item.
* Function signature for using an item (always uses once).
*/
export type UseItemFunction = (
itemId: string,
action: InventoryAction,
quantity?: number
) => Promise<UseItemResult>;
/**
@@ -37,7 +36,7 @@ export type UseItemFunction = (
*/
export interface BlobbiActionsContextValue {
/**
* Use an inventory item on the current companion.
* Use an item on the current companion.
* Works even without BlobbiPage registration (uses fallback).
*/
useItem: UseItemFunction;
@@ -48,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;
}
/**
@@ -439,32 +439,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;
@@ -626,8 +609,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);
}
@@ -646,8 +629,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;
@@ -658,7 +639,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);
}
@@ -687,7 +667,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);
}
@@ -714,11 +693,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)
//
@@ -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';
@@ -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 '@/blobbi/core/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
@@ -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,61 +129,42 @@ 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
// Core mutation for using items (always single-use)
const mutation = useMutation({
mutationFn: async ({
itemId,
action,
quantity = 1,
}: {
itemId: string;
action: InventoryAction;
quantity?: number;
}): 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');
@@ -248,43 +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');
}
// Validate quantity
if (quantity < 1) {
throw new Error('Quantity must be at least 1');
}
// 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');
@@ -302,79 +215,48 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
lastDecayAt: companion.lastDecayAt,
now,
});
// Start with decayed stats as the base
const statsAfterDecay = decayResult.stats;
// ─── Apply Item Effects ───
// ─── Apply Item Effects (single use) ───
const isEggCompanion = companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
if (isEggCompanion && action === 'medicine') {
const healthDelta = shopItem.effect.health ?? 0;
let currentHealth = statsAfterDecay.health ?? 0;
for (let i = 0; i < quantity; i++) {
currentHealth = applyStat(currentHealth, healthDelta);
}
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';
statsUpdate.energy = '100';
} else if (isEggCompanion && action === 'clean') {
const hygieneDelta = shopItem.effect.hygiene ?? 0;
const happinessDelta = shopItem.effect.happiness ?? 0;
let currentHygiene = statsAfterDecay.hygiene ?? 0;
let currentHappiness = statsAfterDecay.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
currentHygiene = applyStat(currentHygiene, hygieneDelta);
currentHappiness = applyStat(currentHappiness, happinessDelta);
}
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
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
for (let i = 0; i < quantity; i++) {
currentStats = applyItemEffects(currentStats, shopItem.effect);
}
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') {
@@ -383,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, {
@@ -400,31 +281,27 @@ 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 };
},
onSuccess: (_, { itemId, action, quantity = 1 }) => {
onSuccess: (_, { itemId, action }) => {
const shopItem = getShopItemById(itemId);
const actionMeta = ACTION_METADATA[action];
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${shopItem?.name ?? 'item'}${quantityText} on your Blobbi.`,
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({
@@ -433,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
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
// Check cooldown first
// Wrapper function that matches UseItemFunction signature
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
// Check shared cooldown first
if (isItemOnCooldown(itemId)) {
if (import.meta.env.DEV) {
console.log('[useBlobbiItemUse] Item on cooldown, skipping:', itemId);
@@ -452,7 +329,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
}
try {
const result = await mutation.mutateAsync({ itemId, action, quantity });
const result = await mutation.mutateAsync({ itemId, action });
return {
success: true,
statsChanged: result.statsChanged,
@@ -463,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(() => {
@@ -475,6 +352,5 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
canUseItems,
isUsingItem: mutation.isPending,
isItemOnCooldown,
clearItemCooldown,
};
}
@@ -42,7 +42,6 @@ export interface ItemUseResult {
export type UseItemCallback = (
itemId: string,
action: InventoryAction,
quantity: number
) => Promise<{ success: boolean; statsChanged?: Record<string, number>; error?: string }>;
/**
@@ -187,7 +186,7 @@ export function useCompanionItemUse({
try {
// Execute the use callback
const useResult = await onUseItem(item.id, inventoryAction, 1);
const useResult = await onUseItem(item.id, inventoryAction);
if (useResult.success) {
const result: ItemUseResult = {
@@ -1,17 +1,15 @@
import { useMemo, useState } from 'react';
import { Package, Loader2, Minus, Plus, X } from 'lucide-react';
import { useMemo } from 'react';
import { Package, Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Tooltip,
TooltipContent,
@@ -31,26 +29,25 @@ interface BlobbiInventoryModalProps {
profile: BlobbonautProfile | null;
/** The current companion (needed for stage-based restrictions) */
companion: BlobbiCompanion | null;
/** Called when user wants to use an item. Opens the use flow. */
onUseItem?: (itemId: string, quantity: number) => void;
/** Called when user wants to use an item. Always uses once. */
onUseItem?: (itemId: string) => void;
/** Whether an item is currently being used */
isUsingItem?: boolean;
}
/** Resolved inventory item with shop metadata and usability info */
/** Resolved catalog item with shop metadata and usability info */
interface ResolvedInventoryItem extends ShopItem {
itemId: string;
quantity: number;
canUse: boolean;
reason?: string;
}
// ── Shared inventory content (used by both standalone modal and unified shop modal) ──
// ── Shared items content (used by both standalone modal and unified shop modal) ──
interface BlobbiInventoryContentProps {
profile: BlobbonautProfile | null;
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string, quantity: number) => void;
onUseItem?: (itemId: string) => void;
isUsingItem?: boolean;
}
@@ -60,10 +57,6 @@ export function BlobbiInventoryContent({
onUseItem,
isUsingItem = false,
}: BlobbiInventoryContentProps) {
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
const [quantity, setQuantity] = useState(1);
const [showUseDialog, setShowUseDialog] = useState(false);
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
const stage = companion?.stage ?? 'egg';
const allItems = getLiveShopItems();
@@ -75,7 +68,6 @@ export function BlobbiInventoryContent({
result.push({
...item,
itemId: item.id,
quantity: Infinity,
canUse: usability.canUse,
reason: usability.reason,
});
@@ -85,166 +77,121 @@ export function BlobbiInventoryContent({
const isEmpty = inventoryItems.length === 0;
const handleSelectItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem) return;
setSelectedItem(item);
setQuantity(1);
setShowUseDialog(true);
};
const handleConfirmUse = () => {
if (!selectedItem || !onUseItem || isUsingItem) return;
onUseItem(selectedItem.itemId, quantity);
setShowUseDialog(false);
setSelectedItem(null);
setQuantity(1);
};
const handleCloseUseDialog = (isOpen: boolean) => {
if (!isOpen) {
setShowUseDialog(false);
setSelectedItem(null);
setQuantity(1);
}
};
const maxQuantity = 99;
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 1) {
setQuantity(1);
} else {
setQuantity(Math.min(value, maxQuantity));
}
const handleUseItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem || !onUseItem) return;
onUseItem(item.itemId);
};
return (
<>
<div className="px-4 sm:px-6 py-3 sm:py-4">
{isEmpty ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-10 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
No items are available for your Blobbi's current stage.
</p>
<div className="px-4 sm:px-6 py-3 sm:py-4">
{isEmpty ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-10 text-muted-foreground" />
</div>
) : (
<div className="grid gap-2 sm:gap-3">
{inventoryItems.map(item => (
<div
key={item.itemId}
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
item.canUse ? "hover:border-primary/30" : "opacity-70"
)}
>
{/* Top row on mobile: Icon + Name/Type + Quantity + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className={cn(
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
!item.canUse && "grayscale"
)}>
{item.icon}
</div>
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
No items are available for your Blobbi's current stage.
</p>
</div>
) : (
<div className="grid gap-2 sm:gap-3">
{inventoryItems.map(item => (
<div
key={item.itemId}
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
item.canUse ? "hover:border-primary/30" : "opacity-70"
)}
>
{/* Top row on mobile: Icon + Name/Type + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className={cn(
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
!item.canUse && "grayscale"
)}>
{item.icon}
</div>
{/* Item Info - Name and Type */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
{item.type}
</Badge>
</div>
{/* Effect preview - desktop only inline */}
<div className="hidden sm:block">
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{/* Show blocked reason - desktop only inline */}
{!item.canUse && item.reason && (
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
{item.reason}
</p>
)}
</div>
{/* Use Button */}
{onUseItem && (
item.canUse ? (
<Button
size="sm"
onClick={() => handleSelectItem(item)}
disabled={isUsingItem}
className="shrink-0"
>
Use
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
disabled
className="shrink-0"
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)
)}
</div>
{/* Mobile only: Effect preview and blocked reason below */}
<div className="sm:hidden pl-13 space-y-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs capitalize">
{/* Item Info - Name and Type */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
{item.type}
</Badge>
</div>
{/* Effect preview - desktop only inline */}
<div className="hidden sm:block">
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{/* Show blocked reason - desktop only inline */}
{!item.canUse && item.reason && (
<p className="text-xs text-amber-600 dark:text-amber-400">
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
{item.reason}
</p>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Use Item Confirmation Dialog */}
{selectedItem && companion && (
<InventoryUseConfirmDialog
open={showUseDialog}
onOpenChange={handleCloseUseDialog}
item={selectedItem}
companion={companion}
quantity={quantity}
maxQuantity={maxQuantity}
onIncrease={handleIncrease}
onDecrease={handleDecrease}
onQuantityChange={handleQuantityInput}
onConfirm={handleConfirmUse}
isUsing={isUsingItem}
/>
{/* Use Button */}
{onUseItem && (
item.canUse ? (
<Button
size="sm"
onClick={() => handleUseItem(item)}
disabled={isUsingItem}
className="shrink-0"
>
{isUsingItem ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Use'
)}
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
disabled
className="shrink-0"
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)
)}
</div>
{/* Mobile only: Effect preview and blocked reason below */}
<div className="sm:hidden pl-13 space-y-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs capitalize">
{item.type}
</Badge>
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{!item.canUse && item.reason && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{item.reason}
</p>
)}
</div>
</div>
))}
</div>
)}
</>
</div>
);
}
@@ -268,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" />
@@ -290,147 +237,3 @@ export function BlobbiInventoryModal({
</Dialog>
);
}
// ─── Use Confirmation Dialog ──────────────────────────────────────────────────
interface InventoryUseConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
quantity: number;
maxQuantity: number;
onIncrease: () => void;
onDecrease: () => void;
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onConfirm: () => void;
isUsing: boolean;
}
function InventoryUseConfirmDialog({
open,
onOpenChange,
item,
companion,
quantity,
maxQuantity,
onIncrease,
onDecrease,
onQuantityChange,
onConfirm,
isUsing,
}: InventoryUseConfirmDialogProps) {
const totalEffect = useMemo(() => {
if (!item.effect) return null;
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
const currentStats = { ...companion.stats };
for (let i = 0; i < quantity; i++) {
for (const stat of statKeys) {
const delta = item.effect[stat];
if (delta !== undefined) {
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
}
}
}
const result: Record<string, number> = {};
for (const stat of statKeys) {
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
if (delta !== 0) {
result[stat] = delta;
}
}
return Object.keys(result).length > 0 ? result : null;
}, [item.effect, companion.stats, quantity]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
<DialogHeader>
<DialogTitle>Use Item</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Item Preview */}
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{item.name}</h3>
</div>
</div>
{/* Quantity Selector */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Quantity</label>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={onDecrease}
disabled={quantity <= 1 || isUsing}
>
<Minus className="size-4" />
</Button>
<Input
type="number"
min="1"
max={maxQuantity}
value={quantity}
onChange={onQuantityChange}
disabled={isUsing}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={onIncrease}
disabled={quantity >= maxQuantity || isUsing}
>
<Plus className="size-4" />
</Button>
</div>
</div>
{/* Effects Summary */}
{totalEffect && (
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
<h4 className="text-sm font-medium mb-2">
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
</h4>
<ItemEffectDisplay effect={totalEffect} variant="badges" />
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isUsing}
>
Cancel
</Button>
<Button
onClick={onConfirm}
disabled={isUsing}
className="min-w-24"
>
{isUsing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Using...
</>
) : (
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -39,7 +39,7 @@ interface BlobbiShopModalProps {
initialTab?: TopTab;
// ── Inventory props (passed through) ──
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string, quantity: number) => void;
onUseItem?: (itemId: string) => void;
isUsingItem?: boolean;
}
@@ -106,7 +106,7 @@ export function BlobbiShopModal({
const handleUseItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem || !onUseItem) return;
setUsingItemId(item.itemId);
onUseItem(item.itemId, 1);
onUseItem(item.itemId);
};
// Clear usingItemId when isUsingItem goes false
+16 -10
View File
@@ -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 { isItemOnCooldown } from '@/blobbi/actions/lib/item-cooldown';
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';
@@ -407,9 +408,9 @@ function BlobbiContent() {
updateProfileEvent,
});
// Handler for using an inventory item (with optional quantity)
const handleUseItem = useCallback(async (itemId: string, action: InventoryAction, quantity: number = 1) => {
await executeUseItem({ itemId, action, quantity });
// Handler for using an item (always uses once)
const handleUseItem = useCallback(async (itemId: string, action: InventoryAction) => {
await executeUseItem({ itemId, action });
}, [executeUseItem]);
// ─── Blobbi Actions Registration ───
@@ -418,9 +419,9 @@ function BlobbiContent() {
// Only provide the function when companion and profile are available
if (!companion || !profile) return null;
return async (itemId, action, quantity = 1) => {
return async (itemId, action) => {
try {
const result = await executeUseItem({ itemId, action, quantity });
const result = await executeUseItem({ itemId, action });
return {
success: true,
statsChanged: result?.statsChanged,
@@ -792,6 +793,7 @@ function BlobbiContent() {
invalidateCompanion={invalidateCompanion}
setStoredSelectedD={setStoredSelectedD}
ensureCanonicalBeforeAction={ensureCanonicalBeforeAction}
isItemOnCooldown={isItemOnCooldown}
// DEV ONLY: State editor props
showDevEditor={showDevEditor}
setShowDevEditor={setShowDevEditor}
@@ -836,7 +838,7 @@ interface BlobbiDashboardProps {
selectedD: string;
onSelectBlobbi: (d: string) => void;
onRest: () => void;
onUseItem: (itemId: string, action: InventoryAction, quantity?: number) => Promise<void>;
onUseItem: (itemId: string, action: InventoryAction) => Promise<void>;
onDirectAction: (action: DirectAction) => Promise<void>;
isUsingItem: boolean;
isDirectActionPending: boolean;
@@ -863,6 +865,8 @@ interface BlobbiDashboardProps {
profileAllTags: string[][];
profileStorage: StorageItem[];
} | null>;
/** Check whether a specific item is on cooldown */
isItemOnCooldown: (itemId: string) => boolean;
// DEV ONLY: State editor props
showDevEditor: boolean;
setShowDevEditor: (show: boolean) => void;
@@ -893,6 +897,7 @@ function BlobbiDashboard({
invalidateCompanion,
setStoredSelectedD,
ensureCanonicalBeforeAction,
isItemOnCooldown: isItemOnCooldownFn,
// DEV ONLY
showDevEditor,
setShowDevEditor,
@@ -1347,14 +1352,14 @@ function BlobbiDashboard({
setShowTrackPickerModal(true);
};
// Handle using an item (with optional quantity)
const handleUseItem = async (itemId: string, quantity: number = 1) => {
// Handle using an item (always uses once)
const handleUseItem = async (itemId: string) => {
if (!inventoryAction || isUsingItem) return;
setUsingItemId(itemId);
// Set action emotion override while item is being used
setActionOverrideEmotion(getActionEmotion(inventoryAction as ActionType));
try {
await onUseItem(itemId, inventoryAction, quantity);
await onUseItem(itemId, inventoryAction);
// Close the modal on success
setInventoryAction(null);
} finally {
@@ -1382,7 +1387,7 @@ function BlobbiDashboard({
if (!action || isUsingItem) return;
setUsingItemId(itemId);
setActionOverrideEmotion(getActionEmotion(action as ActionType));
onUseItem(itemId, action, 1).finally(() => {
onUseItem(itemId, action).finally(() => {
setUsingItemId(null);
setTimeout(() => setActionOverrideEmotion(null), 1500);
});
@@ -1730,6 +1735,7 @@ function BlobbiDashboard({
onOpenShop={handleOpenShopFromAction}
isUsingItem={isUsingItem}
usingItemId={usingItemId}
isItemOnCooldown={isItemOnCooldownFn}
/>
)}