Compare commits

...

5 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
filemon 61c84ed137 Fix conditional hook call in BlobbiStateCard
Move the early return for null companion below all hooks so useMemo
calls are unconditional. The null/egg guard is now inside the recipe
useMemo, and isSleeping/isEgg use optional chaining.
2026-04-06 13:46:50 -03:00
filemon a24b755e08 Use projected decay stats for feed Blobbi visuals
Replace raw companion.stats with calculateProjectedDecay() output so
feed cards reflect the Blobbi's real current condition after time-based
stat decay, matching what the room view shows via useProjectedBlobbiState.

The pure calculateProjectedDecay() function is called once per render
inside useMemo (no setInterval per card), keeping feed rendering
lightweight while staying consistent with the room's decay math.
2026-04-06 13:28:42 -03:00
filemon 46a970b900 Reflect companion condition in feed Blobbi cards
BlobbiStateCard now resolves the same status recipe used by the room
view (resolveStatusRecipe) from the on-chain stats, so feed Blobbis
show hunger, dirt, sleepiness, sadness, and sickness visuals.

A new attenuateRecipeForFeed() helper scales down body-effect particle
counts and removes flies to keep the smaller feed-card size readable.
Sleeping Blobbis get the buildSleepingRecipe() overlay, matching the
room behaviour.
2026-04-06 12:42:57 -03:00
6 changed files with 156 additions and 38 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,
+48
View File
@@ -876,3 +876,51 @@ export const ACTION_EMOTION_MAP: Record<ActionType, BlobbiEmotion> = {
export function getActionEmotion(action: ActionType): BlobbiEmotion {
return ACTION_EMOTION_MAP[action];
}
// ─── Feed Attenuation ─────────────────────────────────────────────────────────
/**
* Produce a lighter version of a visual recipe suitable for feed cards.
*
* Feed Blobbis are rendered at a smaller size (size-48/56 vs size-64+) and
* need to remain readable at a glance. This function keeps all facial parts
* (eyes, mouth, eyebrows) and extras untouched — they are already sized
* relative to the SVG viewBox — but reduces body-effect particle counts
* and removes flies to prevent visual clutter at small sizes.
*
* The input recipe is produced by the same `resolveStatusRecipe()` used
* by the room view, so thresholds and priorities are identical.
*/
export function attenuateRecipeForFeed(recipe: BlobbiVisualRecipe): BlobbiVisualRecipe {
// Empty / no body effects → return as-is (stable reference path)
if (!recipe.bodyEffects) return recipe;
const { bodyEffects, ...rest } = recipe;
const attenuated: BodyEffectsRecipe = {};
// Dirt marks: reduce count by ~40%, lower intensity cap
if (bodyEffects.dirtMarks?.enabled) {
attenuated.dirtMarks = {
...bodyEffects.dirtMarks,
count: Math.max(1, Math.ceil((bodyEffects.dirtMarks.count ?? 3) * 0.6)),
intensity: Math.min(bodyEffects.dirtMarks.intensity ?? 0.6, 0.55),
};
}
// Stink clouds: reduce count, remove flies entirely
if (bodyEffects.stinkClouds?.enabled) {
attenuated.stinkClouds = {
...bodyEffects.stinkClouds,
count: Math.max(1, Math.ceil((bodyEffects.stinkClouds.count ?? 3) * 0.5)),
flies: false,
flyCount: 0,
};
}
// Anger rise: pass through unchanged (single overlay, scales with SVG)
if (bodyEffects.angerRise) {
attenuated.angerRise = bodyEffects.angerRise;
}
return { ...rest, bodyEffects: attenuated };
}
+29 -3
View File
@@ -3,17 +3,41 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { parseBlobbiEvent } from '@/blobbi/core/lib/blobbi';
import { calculateProjectedDecay } from '@/blobbi/core/hooks/useProjectedBlobbiState';
import { resolveStatusRecipe, attenuateRecipeForFeed, EMPTY_RECIPE } from '@/blobbi/ui/lib/status-reactions';
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
export function BlobbiStateCard({ event }: { event: NostrEvent }) {
const companion = useMemo(() => parseBlobbiEvent(event), [event]);
if (!companion) return null;
const isSleeping = companion?.state === 'sleeping';
const isEgg = companion?.stage === 'egg';
const isSleeping = companion.state === 'sleeping';
// ── Project stats forward in time, then resolve visual recipe ──
// Feed cards show a snapshot, not a live ticker, so we call the pure
// calculateProjectedDecay() once per render instead of using the
// interval-based useProjectedBlobbiState hook. This gives us the
// same decay math the room view uses (applyBlobbiDecay under the
// hood) without any per-card setInterval overhead.
const { recipe: feedRecipe, recipeLabel: feedRecipeLabel } = useMemo(() => {
if (!companion || isEgg) return { recipe: EMPTY_RECIPE, recipeLabel: 'neutral' };
const { stats } = calculateProjectedDecay(companion);
const result = resolveStatusRecipe(stats);
// Attenuate body effects for feed-card size, then apply sleep overlay
const attenuated = attenuateRecipeForFeed(result.recipe);
const final = isSleeping ? buildSleepingRecipe(attenuated) : attenuated;
return { recipe: final, recipeLabel: isSleeping ? 'sleeping' : result.label };
}, [companion, isEgg, isSleeping]);
if (!companion) return null;
return (
<div className="flex flex-col items-center py-4">
{/* Blobbi visual — same as /blobbi hero */}
{/* Blobbi visual — reflects current condition */}
<div className="relative">
<div className="absolute inset-0 -m-8 bg-primary/5 rounded-full blur-3xl" />
<BlobbiStageVisual
@@ -21,6 +45,8 @@ export function BlobbiStateCard({ event }: { event: NostrEvent }) {
size="lg"
animated={!isSleeping}
lookMode="forward"
recipe={feedRecipe}
recipeLabel={feedRecipeLabel}
className="size-48 sm:size-56"
/>
</div>
+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}