Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2268a6675b | |||
| c6f203588f | |||
| 61c84ed137 | |||
| a24b755e08 | |||
| 46a970b900 |
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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