Compare commits

...

2 Commits

Author SHA1 Message Date
filemon 2268a6675b Fix item reaction drift and canBeCompanion egg guard
useCompanionItemReaction: cache the raw BlobbiCompanion in the query
instead of pre-projected stats. Projection now happens at call-time
inside checkItemNeed/reactToItemLanding via a ref + getProjectedStats
helper, so each invocation uses a fresh Date.now() for decay math
regardless of React Query staleTime.

canBeCompanion: remove 'egg' from the stage check. The comment and
toast already said only baby/adult are allowed, but the condition
included egg, making the guard a no-op.
2026-04-06 14:20:40 -03:00
filemon c6f203588f Standardize projected stats across all Blobbi UI read points
Audit and fix four places that were reading raw persisted stats instead
of projected/decayed values:

- companionNeedsCare(): care badge on selector strip now uses
  calculateProjectedDecay so the amber ! appears based on real-time
  condition, not stale event data.

- BlobbiActionInventoryModal: item-effect previews now receive
  projectedStats from BlobbiPage's currentStats, so '+X health'
  predictions match the Blobbi's actual current values.

- useCompanionItemReaction: need detection for floating companion
  walk-toward-item behavior now projects stats after fetching the
  fresh event, so the companion reacts to items it actually needs.

- companionDataToBlobbi adapter: was hardcoding all stats to 100
  except energy, ignoring the projected stats already present in
  CompanionData. Now passes companion.stats through directly.
2026-04-06 14:06:27 -03:00
4 changed files with 79 additions and 35 deletions
@@ -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,
+5 -7
View File
@@ -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,
+12 -8
View File
@@ -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}