Wire segment display model into stat indicators and care badge

Replace scattered warning/critical status checks with careState from
getBlobbiStatDisplayState in the three UI consumers:

- StatIndicator (shared): new careState prop takes precedence over
  deprecated status prop; badge shown for attention/urgent, pulse for
  urgent only.
- BlobbiRoomHero (inline indicator): same careState-driven logic.
- BlobbiWidget: passes careState instead of getStatStatus result.
- BlobbiPage companion selector: care badge now shows when any stat is
  urgent OR two-plus stats are attention (was: any stat < 40). Eggs are
  always protected so they never trigger the badge.

Old threshold constants and getStatStatus are kept — no deletions.
No changes to decay rates, sleep, items, status-reactions,
needDetection, SVG ring rendering, or Nostr persistence.
This commit is contained in:
filemon
2026-04-24 13:03:09 -03:00
parent 03dcc37083
commit 08be5e9985
4 changed files with 69 additions and 32 deletions
+23 -15
View File
@@ -13,8 +13,10 @@ import {
} from 'lucide-react';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import { getVisibleStats } from '@/blobbi/core/lib/blobbi-decay';
import { getBlobbiStatDisplayState } from '@/blobbi/core/lib/blobbi-segments';
import type { CareState } from '@/blobbi/core/lib/blobbi-segments';
import type { BlobbiCompanion, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { BlobbiReactionState } from '@/blobbi/actions';
@@ -205,12 +207,16 @@ function StatsCrown({
heroWidth: number;
}) {
const allStats = useMemo(() =>
getVisibleStats(companion.stage).map(stat => ({
stat,
value: currentStats[stat] ?? 100,
status: getStatStatus(companion.stage, stat, currentStats[stat] ?? 100),
color: STAT_COLOR_MAP[stat],
})),
getVisibleStats(companion.stage).map(stat => {
const value = currentStats[stat] ?? 100;
const display = getBlobbiStatDisplayState({ stage: companion.stage, stat: stat as keyof BlobbiStats, value });
return {
stat,
value,
careState: display.careState,
color: STAT_COLOR_MAP[stat],
};
}),
[companion.stage, currentStats]);
if (allStats.length === 0) return null;
@@ -244,7 +250,7 @@ function StatsCrown({
bottom: `${y.toFixed(1)}px`,
}}
>
<StatIndicator stat={s.stat} value={s.value} color={s.color} status={s.status} />
<StatIndicator stat={s.stat} value={s.value} color={s.color} careState={s.careState} />
</div>
);
})}
@@ -258,15 +264,17 @@ function StatIndicator({
stat,
value,
color,
status = 'normal',
careState = 'good',
}: {
stat: string;
value: number | undefined;
color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet';
status?: 'normal' | 'warning' | 'critical';
careState?: CareState;
}) {
const displayValue = value ?? 0;
const isLow = status === 'warning' || status === 'critical';
const showBadge = careState === 'attention' || careState === 'urgent';
const showPulse = careState === 'urgent';
const badgeColor = careState === 'urgent' ? 'text-red-500' : 'text-amber-500';
const ringHex = STAT_RING_HEX[color];
const IconComponent = STAT_ICON_MAP[stat];
@@ -274,7 +282,7 @@ function StatIndicator({
<div className={cn(
'relative size-14 sm:size-[4.5rem] rounded-full flex items-center justify-center',
STAT_BG_COLORS[color],
status === 'critical' && 'animate-pulse',
showPulse && 'animate-pulse',
)}>
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-muted/15" />
@@ -287,9 +295,9 @@ function StatIndicator({
</svg>
<div className="relative">
{IconComponent && <IconComponent className={cn('size-5 sm:size-6', STAT_COLORS[color])} strokeWidth={2.5} />}
{isLow && (
{showBadge && (
<AlertTriangle
className={cn('absolute -top-1.5 -right-2 size-3', status === 'critical' ? 'text-red-500' : 'text-amber-500')}
className={cn('absolute -top-1.5 -right-2 size-3', badgeColor)}
strokeWidth={3}
/>
)}
+21 -4
View File
@@ -1,5 +1,6 @@
import { AlertTriangle, Utensils, Gamepad2, Heart, Droplets, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CareState } from '@/blobbi/core/lib/blobbi-segments';
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -61,7 +62,10 @@ export interface StatIndicatorProps {
stat: string;
value: number | undefined;
color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet';
/** @deprecated Prefer `careState` from blobbi-segments. Kept for unmigrated callers. */
status?: 'normal' | 'warning' | 'critical';
/** Segment-model care state. When provided, takes precedence over `status`. */
careState?: CareState;
/** Visual size preset. Default: 'md'. */
size?: 'sm' | 'md';
/** When provided, renders as a clickable button. */
@@ -75,12 +79,25 @@ export function StatIndicator({
value,
color,
status = 'normal',
careState,
size = 'md',
onClick,
disabled,
}: StatIndicatorProps) {
const displayValue = value ?? 0;
const isLow = status === 'warning' || status === 'critical';
// When careState is provided (new segment model), derive badge/pulse from it.
// Otherwise fall back to old status-based behaviour for unmigrated callers.
const showBadge = careState
? (careState === 'attention' || careState === 'urgent')
: (status === 'warning' || status === 'critical');
const showPulse = careState
? careState === 'urgent'
: status === 'critical';
const badgeColor = careState
? (careState === 'urgent' ? 'text-red-500' : 'text-amber-500')
: (status === 'critical' ? 'text-red-500' : 'text-amber-500');
const ringHex = STAT_RING_HEX[color];
const IconComponent = STAT_ICON_MAP[stat];
const preset = SIZE_PRESETS[size];
@@ -100,9 +117,9 @@ export function StatIndicator({
{/* Icon with warning badge */}
<div className="relative">
{IconComponent && <IconComponent className={cn(preset.icon, STAT_COLORS[color])} strokeWidth={2.5} />}
{isLow && (
{showBadge && (
<AlertTriangle
className={cn('absolute', preset.alertPos, preset.alertSize, status === 'critical' ? 'text-red-500' : 'text-amber-500')}
className={cn('absolute', preset.alertPos, preset.alertSize, badgeColor)}
strokeWidth={3}
/>
)}
@@ -114,7 +131,7 @@ export function StatIndicator({
'relative rounded-full flex items-center justify-center',
preset.container,
STAT_BG_COLORS[color],
status === 'critical' && 'animate-pulse',
showPulse && 'animate-pulse',
);
if (onClick) {
+3 -2
View File
@@ -12,7 +12,8 @@ import { useBlobbiCompanionData } from '@/blobbi/companion/hooks/useBlobbiCompan
import { useBlobbiMigration } from '@/blobbi/core/hooks/useBlobbiMigration';
import { useBlobbiUseInventoryItem } from '@/blobbi/actions/hooks/useBlobbiUseInventoryItem';
import { isActionVisibleForStage, type InventoryAction, type BlobbiAction } from '@/blobbi/actions/lib/blobbi-action-utils';
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
import { getVisibleStats } from '@/blobbi/core/lib/blobbi-decay';
import { getBlobbiStatDisplayState } from '@/blobbi/core/lib/blobbi-segments';
import { KIND_BLOBBI_STATE, KIND_BLOBBONAUT_PROFILE, updateBlobbiTags, updateBlobbonautTags, filterMigratedLegacyCompanions } from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
@@ -364,7 +365,7 @@ function BlobbiWidgetContent({ companion, onUseItem, onRest, isActionPending, is
stat={stat}
value={stats[stat]}
color={STAT_COLOR_MAP[stat]}
status={getStatStatus(stage, stat, stats[stat] ?? 100)}
careState={getBlobbiStatDisplayState({ stage, stat, value: stats[stat] ?? 100 }).careState}
size="sm"
onClick={() => handleStatClick(stat)}
disabled={isActionPending}
+22 -11
View File
@@ -48,6 +48,7 @@ import {
import { useSeedIdentitySync } from '@/blobbi/core/hooks/useSeedIdentitySync';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getBlobbiStatDisplayState } from '@/blobbi/core/lib/blobbi-segments';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
@@ -121,21 +122,31 @@ function getSelectedBlobbiKey(pubkey: string): string {
/** Enable debug logging in development only */
const DEBUG_BLOBBI = import.meta.env.DEV;
/** Stat threshold below which a Blobbi is considered to need care */
const CARE_THRESHOLD = 40;
/** @deprecated Kept for reference — care badge now uses segment model. */
const _CARE_THRESHOLD = 40;
void _CARE_THRESHOLD;
/** Stat keys checked for the companion selector care badge (excludes energy). */
const CARE_BADGE_STATS = ['hunger', 'happiness', 'hygiene', 'health'] as const;
/**
* Check if a companion needs care based on stat thresholds.
* A Blobbi needs care if any stat is below CARE_THRESHOLD.
* Check if a companion needs care using the segment display model.
*
* Shows a care badge when:
* - any stat is `urgent`, OR
* - two or more stats are `attention`.
*
* Eggs always return `protected` from the helper, so they never show a badge.
*/
function companionNeedsCare(companion: BlobbiCompanion): boolean {
const { stats } = 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)
);
let attentionCount = 0;
for (const stat of CARE_BADGE_STATS) {
const value = companion.stats[stat] ?? 100;
const { careState } = getBlobbiStatDisplayState({ stage: companion.stage, stat, value });
if (careState === 'urgent') return true;
if (careState === 'attention') attentionCount++;
}
return attentionCount >= 2;
}