Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2268a6675b | |||
| c6f203588f |
@@ -12,7 +12,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
@@ -33,6 +33,8 @@ interface BlobbiActionInventoryModalProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
action: InventoryAction;
|
||||
companion: BlobbiCompanion;
|
||||
/** Projected stats (with decay applied). Used for preview accuracy. */
|
||||
projectedStats?: BlobbiStats;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called when user taps Use on an item. Always uses once. */
|
||||
onUseItem: (itemId: string) => void;
|
||||
@@ -46,6 +48,7 @@ export function BlobbiActionInventoryModal({
|
||||
onOpenChange,
|
||||
action,
|
||||
companion,
|
||||
projectedStats,
|
||||
profile: _profile,
|
||||
onUseItem,
|
||||
onOpenShop: _onOpenShop,
|
||||
@@ -131,6 +134,7 @@ export function BlobbiActionInventoryModal({
|
||||
key={item.itemId}
|
||||
item={item}
|
||||
companion={companion}
|
||||
projectedStats={projectedStats}
|
||||
action={action}
|
||||
onUse={() => handleUseItem(item)}
|
||||
isUsing={isUsingItem && usingItemId === item.itemId}
|
||||
@@ -150,6 +154,8 @@ export function BlobbiActionInventoryModal({
|
||||
interface BlobbiInventoryUseRowProps {
|
||||
item: ResolvedInventoryItem;
|
||||
companion: BlobbiCompanion;
|
||||
/** Projected stats (with decay applied). Falls back to raw event stats. */
|
||||
projectedStats?: BlobbiStats;
|
||||
action: InventoryAction;
|
||||
onUse: () => void;
|
||||
isUsing: boolean;
|
||||
@@ -159,6 +165,7 @@ interface BlobbiInventoryUseRowProps {
|
||||
function BlobbiInventoryUseRow({
|
||||
item,
|
||||
companion,
|
||||
projectedStats,
|
||||
action,
|
||||
onUse,
|
||||
isUsing,
|
||||
@@ -168,28 +175,32 @@ function BlobbiInventoryUseRow({
|
||||
const isMedicine = action === 'medicine';
|
||||
const isClean = action === 'clean';
|
||||
|
||||
// Use projected stats for preview accuracy; fall back to raw event stats.
|
||||
// Eggs don't decay, so the fallback is always correct for them.
|
||||
const statsForPreview: Partial<BlobbiStats> = projectedStats ?? companion.stats;
|
||||
|
||||
// Preview stat changes - handle egg-specific preview for medicine and clean
|
||||
const { normalStatChanges, eggStatChanges } = useMemo(() => {
|
||||
if (isEgg && isMedicine) {
|
||||
return {
|
||||
normalStatChanges: [],
|
||||
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
|
||||
eggStatChanges: previewMedicineForEgg(statsForPreview.health, item.effect),
|
||||
};
|
||||
}
|
||||
if (isEgg && isClean) {
|
||||
return {
|
||||
normalStatChanges: [],
|
||||
eggStatChanges: previewCleanForEgg(
|
||||
{ hygiene: companion.stats.hygiene, happiness: companion.stats.happiness },
|
||||
{ hygiene: statsForPreview.hygiene, happiness: statsForPreview.happiness },
|
||||
item.effect
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
normalStatChanges: previewStatChanges(companion.stats, item.effect),
|
||||
normalStatChanges: previewStatChanges(statsForPreview, item.effect),
|
||||
eggStatChanges: [] as EggStatPreview[],
|
||||
};
|
||||
}, [companion.stats, item.effect, isEgg, isMedicine, isClean]);
|
||||
}, [statsForPreview, item.effect, isEgg, isMedicine, isClean]);
|
||||
|
||||
const hasChanges = normalStatChanges.length > 0 || eggStatChanges.length > 0;
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@ import {
|
||||
KIND_BLOBBI_STATE,
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
type BlobbiCompanion,
|
||||
type BlobbiStats,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { calculateProjectedDecay } from '@/blobbi/core/hooks/useProjectedBlobbiState';
|
||||
import { checkItemCategoryNeed, type NeedCheckResult } from '../interaction/needDetection';
|
||||
import type { ShopItemCategory } from '@/blobbi/shop/types/shop.types';
|
||||
import type { Position } from '../types/companion.types';
|
||||
@@ -86,10 +88,13 @@ export function useCompanionItemReaction({
|
||||
// Get current companion's d-tag from profile
|
||||
const currentCompanionD = profile?.currentCompanion;
|
||||
|
||||
// Fetch companion stats
|
||||
const statsQuery = useQuery({
|
||||
// Fetch the parsed companion (raw event data).
|
||||
// We cache the BlobbiCompanion itself — NOT projected stats — because
|
||||
// projected values become stale within the staleTime window. Projection
|
||||
// is done at point-of-use below so it is always fresh.
|
||||
const companionQuery = useQuery({
|
||||
queryKey: ['companion-stats', user?.pubkey, currentCompanionD],
|
||||
queryFn: async ({ signal }) => {
|
||||
queryFn: async ({ signal }): Promise<BlobbiCompanion | null> => {
|
||||
if (!user?.pubkey || !currentCompanionD) return null;
|
||||
|
||||
const events = await nostr.query([{
|
||||
@@ -104,38 +109,64 @@ export function useCompanionItemReaction({
|
||||
|
||||
if (validEvents.length === 0) return null;
|
||||
|
||||
const companion = parseBlobbiEvent(validEvents[0]);
|
||||
return companion?.stats ?? null;
|
||||
return parseBlobbiEvent(validEvents[0]) ?? null;
|
||||
},
|
||||
enabled: isActive && !!user?.pubkey && !!currentCompanionD,
|
||||
staleTime: 30_000, // 30 seconds - stats don't change that fast
|
||||
gcTime: 60_000, // 1 minute
|
||||
staleTime: 30_000,
|
||||
gcTime: 60_000,
|
||||
});
|
||||
|
||||
const stats = statsQuery.data ?? null;
|
||||
const hasStats = stats !== null;
|
||||
const cachedCompanion = companionQuery.data ?? null;
|
||||
|
||||
// Keep a ref so callbacks always read the latest cached companion
|
||||
// without needing to be recreated on every query update.
|
||||
const companionRef = useRef<BlobbiCompanion | null>(null);
|
||||
companionRef.current = cachedCompanion;
|
||||
|
||||
/**
|
||||
* Project stats from the cached companion at call-time.
|
||||
* This ensures every invocation uses a fresh Date.now() for decay,
|
||||
* so need detection is accurate even when the underlying query data
|
||||
* hasn't been refetched.
|
||||
*/
|
||||
const getProjectedStats = useCallback((): BlobbiStats | null => {
|
||||
const c = companionRef.current;
|
||||
if (!c) return null;
|
||||
return calculateProjectedDecay(c).stats;
|
||||
}, []);
|
||||
|
||||
const hasStats = cachedCompanion !== null;
|
||||
|
||||
// Expose a snapshot for debugging/display (projected at render time)
|
||||
const stats: Partial<BlobbiStats> | null = hasStats ? getProjectedStats() : null;
|
||||
|
||||
/**
|
||||
* Check if Blobbi needs an item category based on current stats.
|
||||
* Projects stats at call-time for accuracy.
|
||||
*/
|
||||
const checkItemNeed = useCallback((category: ShopItemCategory): ItemReactionResult | null => {
|
||||
if (!stats) return null;
|
||||
const projected = getProjectedStats();
|
||||
if (!projected) return null;
|
||||
|
||||
const needResult = checkItemCategoryNeed(category, stats);
|
||||
const needResult = checkItemCategoryNeed(category, projected);
|
||||
return {
|
||||
needsItem: needResult.needsItem,
|
||||
needResult,
|
||||
};
|
||||
}, [stats]);
|
||||
}, [getProjectedStats]);
|
||||
|
||||
/**
|
||||
* React to an item landing on the ground.
|
||||
* Projects stats at call-time for accuracy.
|
||||
*
|
||||
* - If Blobbi needs the item: walk toward it (via onWalkTo)
|
||||
* - If Blobbi doesn't need the item: glance at it briefly (via onGlance)
|
||||
*/
|
||||
const reactToItemLanding = useCallback((category: ShopItemCategory, position: Position) => {
|
||||
if (!isActive || !stats) return;
|
||||
if (!isActive) return;
|
||||
|
||||
const projected = getProjectedStats();
|
||||
if (!projected) return;
|
||||
|
||||
// Rate limit reactions
|
||||
const now = Date.now();
|
||||
@@ -144,7 +175,7 @@ export function useCompanionItemReaction({
|
||||
}
|
||||
lastReactionTimeRef.current = now;
|
||||
|
||||
const needResult = checkItemCategoryNeed(category, stats);
|
||||
const needResult = checkItemCategoryNeed(category, projected);
|
||||
|
||||
// Delay reaction slightly for more natural feel
|
||||
setTimeout(() => {
|
||||
@@ -170,7 +201,7 @@ export function useCompanionItemReaction({
|
||||
onGlance?.(position);
|
||||
}
|
||||
}, REACTION_CONFIG.reactionDelay);
|
||||
}, [isActive, stats, onGlance, onWalkTo]);
|
||||
}, [isActive, getProjectedStats, onGlance, onWalkTo]);
|
||||
|
||||
return {
|
||||
checkItemNeed,
|
||||
|
||||
@@ -68,13 +68,11 @@ export function companionDataToBlobbi(companion: CompanionData): Blobbi {
|
||||
lifeStage: companion.stage,
|
||||
state: companion.state ?? 'active',
|
||||
isSleeping,
|
||||
stats: {
|
||||
hunger: 100,
|
||||
happiness: 100,
|
||||
health: 100,
|
||||
hygiene: 100,
|
||||
energy: companion.energy,
|
||||
},
|
||||
// Use the full projected stats from CompanionData (populated by
|
||||
// useBlobbiCompanionData with decay applied). The old code hardcoded
|
||||
// every stat to 100 except energy, which was incorrect when the data
|
||||
// pipeline already provides projected values.
|
||||
stats: companion.stats,
|
||||
baseColor: companion.visualTraits.baseColor,
|
||||
secondaryColor: companion.visualTraits.secondaryColor,
|
||||
eyeColor: companion.visualTraits.eyeColor,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { nip19 } from 'nostr-tools';
|
||||
import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, AlertTriangle, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, HeartHandshake, Package, Target, Droplets, Heart, Zap } from 'lucide-react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState';
|
||||
import { useProjectedBlobbiState, calculateProjectedDecay } from '@/blobbi/core/hooks/useProjectedBlobbiState';
|
||||
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
|
||||
@@ -111,15 +111,18 @@ const CARE_THRESHOLD = 40;
|
||||
|
||||
/**
|
||||
* Check if a companion needs care based on stat thresholds.
|
||||
* A Blobbi needs care if any stat is below CARE_THRESHOLD.
|
||||
* A Blobbi needs care if any projected stat is below CARE_THRESHOLD.
|
||||
*
|
||||
* Uses `calculateProjectedDecay` so the badge reflects real-time
|
||||
* condition even when the persisted event hasn't been updated recently.
|
||||
*/
|
||||
function companionNeedsCare(companion: BlobbiCompanion): boolean {
|
||||
const { stats } = companion;
|
||||
const { stats } = calculateProjectedDecay(companion);
|
||||
return (
|
||||
(stats.hunger !== undefined && stats.hunger < CARE_THRESHOLD) ||
|
||||
(stats.happiness !== undefined && stats.happiness < CARE_THRESHOLD) ||
|
||||
(stats.hygiene !== undefined && stats.hygiene < CARE_THRESHOLD) ||
|
||||
(stats.health !== undefined && stats.health < CARE_THRESHOLD)
|
||||
stats.hunger < CARE_THRESHOLD ||
|
||||
stats.happiness < CARE_THRESHOLD ||
|
||||
stats.hygiene < CARE_THRESHOLD ||
|
||||
stats.health < CARE_THRESHOLD
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1179,7 +1182,7 @@ function BlobbiDashboard({
|
||||
const [isUpdatingCompanion, setIsUpdatingCompanion] = useState(false);
|
||||
|
||||
// Check if this Blobbi can be set as companion (must be baby or adult, not egg)
|
||||
const canBeCompanion = companion.stage === 'egg' || companion.stage === 'baby' || companion.stage === 'adult';
|
||||
const canBeCompanion = companion.stage === 'baby' || companion.stage === 'adult';
|
||||
|
||||
// Handler for toggling the current companion
|
||||
const handleSetAsCompanion = useCallback(async () => {
|
||||
@@ -1725,6 +1728,7 @@ function BlobbiDashboard({
|
||||
onOpenChange={(open) => !open && setInventoryAction(null)}
|
||||
action={inventoryAction}
|
||||
companion={companion}
|
||||
projectedStats={currentStats}
|
||||
profile={profile}
|
||||
onUseItem={handleUseItem}
|
||||
onOpenShop={handleOpenShopFromAction}
|
||||
|
||||
Reference in New Issue
Block a user