Remove inventory ownership requirement from Blobbi companion items

Items are now treated as abilities/tools unlocked by stage, not
consumable inventory that must be purchased. All catalog items are
shown in the companion action menu regardless of inventory quantity.

Changes:
- Source items from shop catalog instead of user inventory storage
- Remove quantity validation and storage decrement on item use
- Remove quantity badges and 'in inventory' text from all modals
- Keep stage-based filtering (egg vs baby/adult restrictions)
- Cap quantity selector at 99 instead of inventory count
This commit is contained in:
filemon
2026-04-05 19:59:30 -03:00
parent c1ec7a25ed
commit 361f8b9506
8 changed files with 68 additions and 195 deletions
@@ -1,7 +1,7 @@
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
import { useMemo, useState } from 'react';
import { Loader2, ShoppingBag, Minus, Plus, X } from 'lucide-react';
import { Loader2, Minus, Plus, X } from 'lucide-react';
import {
Dialog,
@@ -49,9 +49,9 @@ export function BlobbiActionInventoryModal({
onOpenChange,
action,
companion,
profile,
profile: _profile,
onUseItem,
onOpenShop,
onOpenShop: _onOpenShop,
isUsingItem,
usingItemId,
}: BlobbiActionInventoryModalProps) {
@@ -62,11 +62,11 @@ export function BlobbiActionInventoryModal({
const [quantity, setQuantity] = useState(1);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
// Filter inventory by action type, respecting egg-compatible effects
// Get all available items for this action from the catalog (not inventory).
// Items are abilities/tools — no ownership required.
const availableItems = useMemo(() => {
if (!profile) return [];
return filterInventoryByAction(profile.storage, action, { stage: companion.stage });
}, [profile, action, companion.stage]);
return filterInventoryByAction([], action, { stage: companion.stage });
}, [action, companion.stage]);
// Check stage restrictions for this specific action
const canUse = canUseAction(companion, action);
@@ -98,13 +98,8 @@ export function BlobbiActionInventoryModal({
}
};
const handleOpenShop = () => {
onOpenChange(false);
onOpenShop();
};
// Quantity controls
const maxQuantity = selectedItem?.quantity ?? 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>) => {
@@ -161,14 +156,10 @@ export function BlobbiActionInventoryModal({
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<span className="text-3xl">{actionMeta.icon}</span>
</div>
<h3 className="text-lg font-semibold mb-2">No Items</h3>
<p className="text-sm text-muted-foreground max-w-sm mb-4">
You don't have any items for this action. Visit the shop to get some!
<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 this action at your Blobbi's current stage.
</p>
<Button onClick={handleOpenShop} className="gap-2">
<ShoppingBag className="size-4" />
Open Shop
</Button>
</div>
)}
@@ -280,9 +271,6 @@ function BlobbiInventoryUseRow({
<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 shrink-0">
x{item.quantity}
</Badge>
</div>
{/* Effect Preview - shown inline on desktop */}
@@ -502,9 +490,6 @@ function BlobbiUseItemConfirmDialog({
<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>
<p className="text-sm text-muted-foreground">
{item.quantity} in inventory
</p>
</div>
</div>
@@ -512,9 +497,6 @@ function BlobbiUseItemConfirmDialog({
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Quantity</label>
<span className="text-xs text-muted-foreground">
Max: {maxQuantity}
</span>
</div>
<div className="flex items-center gap-2">
<Button
@@ -9,16 +9,12 @@ import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
decrementStorageItem,
canUseAction,
getStageRestrictionMessage,
clampStat,
@@ -102,7 +98,7 @@ export function useBlobbiUseInventoryItem({
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
updateProfileEvent,
updateProfileEvent: _updateProfileEvent,
}: UseBlobbiUseInventoryItemParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -391,28 +387,8 @@ export function useBlobbiUseInventoryItem({
updateCompanionEvent(blobbiEvent);
// ─── Update Profile Storage (kind 11125) ───
// Only decrement storage if the item actually exists in inventory.
// Items are free to use regardless of inventory state.
const hasItemInStorage = canonical.profileStorage.some(s => s.itemId === itemId && s.quantity > 0);
if (hasItemInStorage) {
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
storage: storageValues,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
updateProfileEvent(profileEvent);
}
// No query invalidation needed — the optimistic updates above keep the
// 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).
+9 -11
View File
@@ -2,7 +2,7 @@
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import { getShopItemById, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
// ─── Action Types ─────────────────────────────────────────────────────────────
@@ -293,8 +293,8 @@ export interface FilterInventoryOptions {
}
/**
* Filter inventory items by action type.
* Returns resolved items with shop metadata.
* Get all available items for an action type from the shop catalog.
* Items are abilities/tools — no inventory ownership is required.
*
* Filtering rules:
* - Only items matching the action's item type are included
@@ -304,22 +304,20 @@ export interface FilterInventoryOptions {
* - clean action: only items with hygiene or happiness effect
*/
export function filterInventoryByAction(
storage: StorageItem[],
_storage: StorageItem[],
action: InventoryAction,
options: FilterInventoryOptions = {}
): ResolvedInventoryItem[] {
const allowedType = ACTION_TO_ITEM_TYPE[action];
const result: ResolvedInventoryItem[] = [];
const isEgg = options.stage === 'egg';
const allItems = getLiveShopItems();
for (const storageItem of storage) {
const shopItem = getShopItemById(storageItem.itemId);
if (!shopItem) continue;
for (const shopItem of allItems) {
if (shopItem.type !== allowedType) continue;
if (storageItem.quantity <= 0) continue;
// Shell Repair Kit: only show for eggs in medicine modal
if (storageItem.itemId === SHELL_REPAIR_KIT_ID && !isEgg) {
if (shopItem.id === SHELL_REPAIR_KIT_ID && !isEgg) {
continue;
}
@@ -334,8 +332,8 @@ export function filterInventoryByAction(
}
result.push({
itemId: storageItem.itemId,
quantity: storageItem.quantity,
itemId: shopItem.id,
quantity: Infinity,
name: shopItem.name,
icon: shopItem.icon,
type: shopItem.type,
@@ -406,7 +406,7 @@ export function HangingItems({
// Track how many instances of each item type have been released (not yet used)
// Key: item.id (type ID), Value: count of released instances
const [releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
const [_releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
// Counter for generating unique instance IDs
const instanceCounterRef = useRef(0);
@@ -985,15 +985,9 @@ export function HangingItems({
return viewportCenterX + startX + index * HANGING_CONFIG.itemSpacing;
};
// Calculate hanging items with their remaining quantities
// An item appears in the hanging row if (quantity - releasedCount) > 0
const hangingItems = items
.map(item => {
const releasedCount = releasedCountByItemId.get(item.id) || 0;
const remainingQuantity = item.quantity - releasedCount;
return { ...item, quantity: remainingQuantity };
})
.filter(item => item.quantity > 0);
// All items are always visible — they are abilities, not consumable inventory.
// No quantity filtering needed.
const hangingItems = items;
// Should we render the hanging container?
const shouldRenderContainer = containerState !== 'hidden' || (isVisible && selectedAction);
@@ -1033,7 +1027,7 @@ export function HangingItems({
>
<div className="bg-background/95 backdrop-blur-sm rounded-2xl px-6 py-4 shadow-lg border">
<p className="text-sm text-muted-foreground text-center">
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items in your inventory
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items available
</p>
</div>
</div>
@@ -1102,8 +1096,8 @@ export function HangingItems({
marginLeft: (HANGING_CONFIG.circleSize / 2) * -1 + HANGING_CONFIG.lineWidth / 2,
}}
onClick={() => handleItemClick(item, itemX)}
title={`${item.name} (x${item.quantity})`}
aria-label={`${item.name}, quantity ${item.quantity}. Click to release.`}
title={item.name}
aria-label={`${item.name}. Click to release.`}
>
{/* Item emoji */}
<span
@@ -1114,24 +1108,6 @@ export function HangingItems({
>
{item.emoji}
</span>
{/* Quantity badge */}
<span
className={cn(
"absolute -top-1 -right-1",
"flex items-center justify-center",
"bg-primary text-primary-foreground",
"text-xs font-semibold rounded-full",
"shadow-md"
)}
style={{
minWidth: HANGING_CONFIG.badgeSize,
height: HANGING_CONFIG.badgeSize,
padding: '0 5px',
}}
>
{item.quantity}
</span>
</button>
</div>
);
@@ -30,10 +30,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
parseBlobbiEvent,
isValidBlobbiEvent,
} from '@/blobbi/core/lib/blobbi';
@@ -41,7 +38,6 @@ import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
decrementStorageItem,
canUseAction,
canUseItemForStage,
getStageRestrictionMessage,
@@ -126,7 +122,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
const queryClient = useQueryClient();
// Fetch profile if not provided
const { profile: fetchedProfile, updateProfileEvent } = useBlobbonautProfile();
const { profile: fetchedProfile } = useBlobbonautProfile();
const profile = options.profile ?? fetchedProfile;
// Per-item cooldown tracking (ref to avoid re-renders)
@@ -283,15 +279,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
throw new Error(itemUsability.reason ?? 'This item cannot be used by this companion');
}
// Validate item exists in storage with sufficient quantity
const storageItem = profile.storage.find(s => s.itemId === itemId);
if (!storageItem || storageItem.quantity <= 0) {
throw new Error('Item not found in your inventory');
}
if (storageItem.quantity < quantity) {
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
}
// Validate item has effects
if (!shopItem.effect) {
throw new Error('This item has no effect');
@@ -414,24 +401,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
updateCompanionInCache(blobbiEvent);
// ─── Update Profile Storage (kind 11125) ───
const newStorage = decrementStorageItem(profile.storage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
const profileTags = updateBlobbonautTags(profile.allTags, {
storage: storageValues,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
updateProfileEvent(profileEvent);
// ─── Invalidate Queries ───
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
// Items are free to use — no storage decrement needed.
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
return { statsChanged };
@@ -18,8 +18,7 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import type { StorageItem } from '@/blobbi/core/lib/blobbi';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import type {
@@ -68,7 +67,10 @@ interface UseCompanionActionMenuResult {
}
/**
* Resolve inventory items for a specific action/category.
* Resolve available items for a specific action/category from the shop catalog.
*
* Items are sourced from the full catalog (not inventory) — all items are
* available as abilities/tools unlocked by stage, not consumable inventory.
*
* Uses the centralized `canUseItemForStage` function to ensure consistent
* stage-based filtering across all UIs:
@@ -80,7 +82,6 @@ interface UseCompanionActionMenuResult {
* filters out all egg-only items from the companion interaction system.
*/
function resolveItemsForAction(
storage: StorageItem[],
action: CompanionMenuAction,
stage: 'egg' | 'baby' | 'adult'
): CompanionItem[] {
@@ -89,13 +90,10 @@ function resolveItemsForAction(
// Sleep action has no items
if (!category) return [];
const allItems = getLiveShopItems();
const items: CompanionItem[] = [];
for (const storageItem of storage) {
if (storageItem.quantity <= 0) continue;
const shopItem = getShopItemById(storageItem.itemId);
if (!shopItem) continue;
for (const shopItem of allItems) {
if (shopItem.type !== category) continue;
// Use centralized stage-based filtering
@@ -104,17 +102,17 @@ function resolveItemsForAction(
// - Food/Toys: only for baby/adult (excluded for eggs)
// - Medicine: must have health effect
// - Hygiene: must have hygiene or happiness effect
const usability = canUseItemForStage(storageItem.itemId, stage);
const usability = canUseItemForStage(shopItem.id, stage);
if (!usability.canUse) {
continue;
}
items.push({
id: storageItem.itemId,
id: shopItem.id,
name: shopItem.name,
emoji: shopItem.icon,
category: shopItem.type,
quantity: storageItem.quantity,
quantity: Infinity,
effect: shopItem.effect,
});
}
@@ -197,8 +195,8 @@ export function useCompanionActionMenu({
return;
}
// Resolve items for this action
const items = resolveItemsForAction(profile.storage, action, stage);
// Resolve items for this action from the catalog (not inventory)
const items = resolveItemsForAction(action, stage);
setMenuState(prev => ({
...prev,
@@ -20,7 +20,7 @@ import {
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { ShopItem } from '../types/shop.types';
import { getShopItemById } from '../lib/blobbi-shop-items';
import { getLiveShopItems } from '../lib/blobbi-shop-items';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import { cn } from '@/lib/utils';
import { ItemEffectDisplay } from './ItemEffectDisplay';
@@ -55,7 +55,7 @@ interface BlobbiInventoryContentProps {
}
export function BlobbiInventoryContent({
profile,
profile: _profile,
companion,
onUseItem,
isUsingItem = false,
@@ -65,26 +65,23 @@ export function BlobbiInventoryContent({
const [showUseDialog, setShowUseDialog] = useState(false);
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
if (!profile) return [];
const stage = companion?.stage ?? 'egg';
const allItems = getLiveShopItems();
const result: ResolvedInventoryItem[] = [];
for (const storageItem of profile.storage) {
const item = getShopItemById(storageItem.itemId);
if (!item) continue;
const usability = canUseItemForStage(storageItem.itemId, stage);
for (const item of allItems) {
const usability = canUseItemForStage(item.id, stage);
result.push({
...item,
itemId: storageItem.itemId,
quantity: storageItem.quantity,
itemId: item.id,
quantity: Infinity,
canUse: usability.canUse,
reason: usability.reason,
});
}
return result;
}, [profile, companion?.stage]);
}, [companion?.stage]);
const isEmpty = inventoryItems.length === 0;
@@ -111,7 +108,7 @@ export function BlobbiInventoryContent({
}
};
const maxQuantity = selectedItem?.quantity ?? 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>) => {
@@ -131,9 +128,9 @@ export function BlobbiInventoryContent({
<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 Yet</h3>
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
Visit the Shop tab to purchase items for your Blobbi. Items you buy will appear here.
No items are available for your Blobbi's current stage.
</p>
</div>
) : (
@@ -179,11 +176,6 @@ export function BlobbiInventoryContent({
)}
</div>
{/* Quantity Badge */}
<Badge className="bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0 px-2 py-0.5 shrink-0 text-xs">
×{item.quantity}
</Badge>
{/* Use Button */}
{onUseItem && (
item.canUse ? (
@@ -367,9 +359,6 @@ function InventoryUseConfirmDialog({
<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>
<p className="text-sm text-muted-foreground">
{item.quantity} in inventory
</p>
</div>
</div>
@@ -377,9 +366,6 @@ function InventoryUseConfirmDialog({
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Quantity</label>
<span className="text-xs text-muted-foreground">
Max: {maxQuantity}
</span>
</div>
<div className="flex items-center gap-2">
<Button
+12 -26
View File
@@ -16,7 +16,7 @@ import {
import type { ShopItem } from '../types/shop.types';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { getLiveShopItems, getShopItemById } from '../lib/blobbi-shop-items';
import { getLiveShopItems } from '../lib/blobbi-shop-items';
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import { cn, formatCompactNumber } from '@/lib/utils';
@@ -80,28 +80,25 @@ export function BlobbiShopModal({
const effectivePurchasingId = isPurchasing ? purchasingItemId : null;
// ── Inventory items resolution ──
// ── Items resolution — sourced from the full catalog (not inventory) ──
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
if (!profile) return [];
const stage = companion?.stage ?? 'egg';
const allCatalogItems = getLiveShopItems();
const result: ResolvedInventoryItem[] = [];
for (const storageItem of profile.storage) {
const item = getShopItemById(storageItem.itemId);
if (!item) continue;
const usability = canUseItemForStage(storageItem.itemId, stage);
for (const item of allCatalogItems) {
const usability = canUseItemForStage(item.id, stage);
result.push({
...item,
itemId: storageItem.itemId,
quantity: storageItem.quantity,
itemId: item.id,
quantity: Infinity,
canUse: usability.canUse,
reason: usability.reason,
});
}
return result;
}, [profile, companion?.stage]);
}, [companion?.stage]);
// ── Inventory use item handler ──
const [usingItemId, setUsingItemId] = useState<string | null>(null);
@@ -138,7 +135,7 @@ export function BlobbiShopModal({
Items
{!inventoryEmpty && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 min-w-4">
{inventoryItems.reduce((sum, i) => sum + i.quantity, 0)}
{inventoryItems.length}
</Badge>
)}
{topTab === 'items' && (
@@ -275,20 +272,16 @@ interface ItemsGridProps {
onGoToShop: () => void;
}
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: ItemsGridProps) {
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _onGoToShop }: ItemsGridProps) {
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-8 text-muted-foreground/60" />
</div>
<p className="text-sm text-muted-foreground mb-4">
No items yet. Visit the shop to stock up!
<p className="text-sm text-muted-foreground">
No items are available for your Blobbi's current stage.
</p>
<Button variant="outline" size="sm" onClick={onGoToShop} className="gap-2">
<ShoppingBag className="size-3.5" />
Browse Shop
</Button>
</div>
);
}
@@ -308,13 +301,6 @@ function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: I
item.canUse ? 'hover:border-primary/40 hover:bg-accent/40' : 'opacity-60',
)}
>
{/* Quantity badge */}
<Badge
className="absolute top-1.5 right-1.5 text-[10px] px-1.5 py-0 h-4 min-w-4 bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0"
>
{item.quantity}
</Badge>
{/* Icon */}
<div className={cn('text-3xl leading-none mt-1', !item.canUse && 'grayscale')}>{item.icon}</div>