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:
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user