Compare commits

...

10 Commits

Author SHA1 Message Date
filemon 4e5dbed3d2 Fix all Kind 11125 write paths to preserve profile content JSON
Critical safety fix: 9 out of 12 Kind 11125 publish paths were
hardcoding content: '' which would silently erase any structured
content (daily missions data) stored in the profile event.

Every write path that updates an existing profile now preserves
the existing event content via profile.event.content instead of
overwriting with empty string.

Fixed locations:
- useBlobbiOnboarding.ts: reroll preview (line 378) and adopt (476)
- BlobbiHatchingCeremony.tsx: add egg to has[] (302) and mark
  onboarding done (501)
- useBlobbiMigration.ts: legacy migration (190)
- useBlobbonautProfileNormalization.ts: tag normalization (85)
- BlobbiPage.tsx: auto-fix onboardingDone (547) and companion
  toggle (1183)
- useBlobbiPurchaseItem.ts: shop purchase (89)

The only two paths that still use content: '' are initial profile
creation flows (useBlobbiOnboarding auto-create and
BlobbiHatchingCeremony silent setup) where no previous event exists.

The useClaimMissionReward path already uses mergeProfileContent
for proper read-modify-write of the content JSON.
2026-04-06 03:36:20 -03:00
filemon 508a16234f Migrate daily missions to profile content JSON, convert rewards to XP
Major persistence migration for the daily missions system:

Foundation:
- New blobbonaut-content.ts defines BlobbonautProfileContent type with
  dailyMissions shape, plus parseProfileContent/mergeProfileContent for
  safe read-modify-write of Kind 11125 JSON content
- BlobbonautProfile type extended with parsed content field
- parseBlobbonautEvent now parses event.content as structured JSON
  (empty string and invalid JSON handled gracefully)

Reward system changed from coins to XP:
- DailyMissionsState.totalCoinsEarned renamed to totalXpEarned
- All mission reward values rebalanced for XP (10-50 XP range)
- Bonus mission reward: 80 coins -> 50 XP
- useClaimMissionReward now awards XP to the active companion's
  experience tag (Kind 31124) instead of coins to profile
- XP award uses fetchFreshEvent for safe read-modify-write
- XP failure is non-fatal (mission claim still succeeds)
- UI labels updated: 'coins' -> 'XP' throughout

Profile content persistence (Kind 11125):
- useClaimMissionReward now persists mission state to profile content
  JSON via mergeProfileContent, preserving unrelated content fields
- Fresh profile fetched before each write to avoid overwriting
  concurrent changes from other tabs/devices
- Tags preserved unchanged during content-only updates

Backward compatibility:
- localStorage readMissionsState handles legacy totalCoinsEarned field
  by migrating it to totalXpEarned on read
- Daily reset logic in tracker, missions hook, and reroll hook all
  handle legacy field gracefully
- Profile content parsing tolerates empty, malformed, or partial JSON

Files changed:
- New: src/blobbi/core/lib/blobbonaut-content.ts
- Modified: blobbi.ts, BlobbiPage.tsx, useClaimMissionReward.ts,
  useDailyMissions.ts, useRerollMission.ts, daily-mission-tracker.ts,
  daily-missions.ts, BlobbiMissionsModal.tsx, DailyMissionsPanel.tsx
2026-04-06 03:26:57 -03:00
filemon 4ecb3209bd Merge branch 'main' into feat/blobbi-migrate-daily-missions 2026-04-06 03:08:47 -03:00
filemon 286572777b Fix stat crown instability on room switch; hide Closet from navigation
Stat crown fix — root cause and solution:
The heroWidth measurement used a useRef + useEffect([], ...) pattern
that created a ResizeObserver once on mount. When switching rooms,
BlobbiRoomHero unmounts and remounts inside the new room component,
creating a new DOM element. But the ResizeObserver was still watching
the old detached element, so heroWidth went stale (often falling to
the default 375px or reading 0 from the detached node).

Fix: replaced useRef + useEffect with a callback ref (heroCallbackRef)
that disconnects the old ResizeObserver and creates a new one every
time the ref is assigned to a new element. This ensures heroWidth is
always measured from the currently-mounted hero container.

Additionally, the arc spread and radius values were too extreme on
desktop (120/190/230 degrees, 300px radius) which made the stats
fly far apart. Now using balanced moderate values:
- Mobile: 80/110/140 degrees
- Desktop: 90/130/160 degrees
- Radius: smooth 110px → 200px interpolation based on container width

This produces a stable, moderate crown that looks the same on initial
render and after room switches, with desktop having slightly more
breathing room than mobile without being exaggerated.

Closet hidden:
Removed 'closet' from DEFAULT_ROOM_ORDER with a comment explaining
how to re-enable it. The BlobbiClosetRoom component, its type in
BlobbiRoomId, its ROOM_META entry, and its ROOM_COMPONENTS mapping
all remain intact — only the navigation sequence excludes it.
2026-04-06 02:34:57 -03:00
filemon 7fd4b7ab69 Polish carousel stability, desktop stat spacing, poop UX, and visual harmony
Carousel stability:
- Focused item container is now overflow-hidden with explicit dimensions
  (w-20 h-[4.5rem] / sm:w-24 sm:h-[5.5rem]) so no content can push
  the layout wider/taller
- Label uses a fixed max-width (w-16 sm:w-20) with text-center truncate
  so changing items never shifts the arrows
- Preview slots also have overflow-hidden
- Empty state matches the populated carousel height

Desktop stat spacing (mobile unchanged):
- Arc spread widened to 120/190/230 degrees (was 100/160/200)
- Radius scaled up to 300px max (was 260px)
- This gives stat indicators much more breathing room on desktop
  without affecting the mobile layout at all

Poop placement refinement:
- Pre-computed safe-zone positions in lower-left and lower-right
  corners, avoiding the central Blobbi hero area
- Positions stored on each PoopInstance so they're stable
- Shovel mode: active state shows ring indicator around the button
  and label changes to 'Done'; poop gets drop-shadow-lg when
  hoverable; hover:scale-150 for clearer interaction feedback

Care room fix:
- Treat button no longer calls invalid 'interact' DirectAction;
  uses a small food item from shop instead
- Both left/right slots always render the same fixed width
  (RoomActionButton or w-14/w-20 spacer) so switching between
  hygiene/medicine items never causes layout shift

Visual harmony:
- Closet room bottom bar now uses the same flex items-center
  justify-center horizontal layout instead of a different
  flex-col structure, matching the rhythm of all other rooms
2026-04-06 02:28:53 -03:00
filemon c9525a0233 Add carousel stability, sleep overlay, care room conditional actions, poop system
Multiple polish and interaction improvements:

1. Carousel stability:
   Focused item area now uses fixed dimensions (w-20 h-16 / sm:w-24 sm:h-20)
   so the carousel never reflows when switching between items. Arrows stay
   perfectly stable. Preview slots also have fixed w-10 h-12 dimensions.

2. Desktop stat spacing:
   Arc spread widened further on desktop: 100/160/200 degrees (was 90/140/180)
   with radius up to 260px (was 220px). Stats now have much more breathing
   room on desktop while mobile stays unchanged.

3. Bedroom sleep button centered:
   Sleep/wake button is now in the center of the bottom bar instead of
   right-aligned, making the bedroom feel more focused.

4. Sleep dark overlay:
   When Blobbi is sleeping, a radial-gradient dark overlay covers the
   entire room shell (all rooms, not just bedroom). Uses z-20 with
   pointer-events-none so controls remain usable. Scoped to the room
   shell only — app header, bottom nav, and other app UI stay unaffected.

5. Renamed Bathroom → Care Room:
   Room label changed from 'Bathroom' to 'Care Room' with icon changed
   from bathtub to bandage emoji, since the room also contains medicine.

6. Care room conditional side actions:
   ItemCarousel now supports onFocusChange callback and meta field.
   When a hygiene item is focused: Towel (left) + Shower (right).
   When a medicine item is focused: Lollipop/Treat (left) + empty (right).
   Side buttons swap reactively as the user cycles through items.

7. Temporary local poop system:
   - poop-system.ts: ephemeral generation based on hunger >= 95 (overfeed,
     kitchen-only) and hours since last interaction (time-based, random room)
   - Generated once on mount, no persistence
   - Poop appears as floating emoji in affected rooms
   - Kitchen shows a Shovel button when any poop exists anywhere
   - Shovel mode: clicking poop removes it and awards 5 XP via toast
   - All rooms accept RoomPoopState prop for future expansion
   - BlobbiRoomContext extended with lastFeedTimestamp
2026-04-06 02:05:19 -03:00
filemon 0b9cd5e1cb Unify room surface, fix desktop proportions, clear mobile bottom nav
Three structural layout fixes:

1. Remove 'background cut' feeling:
   - Room header (label + dots) is now absolutely positioned over the
     room content instead of being a separate flex block above it. This
     eliminates the stacked-panels look where each section appeared to
     have its own background surface.
   - Hero container no longer uses overflow-hidden; uses pt-10 for
     header clearance instead. The room reads as one continuous scene.
   - Room shell content area fills entirely; header and nav arrows
     float over it as overlays.

2. Desktop proportions tuned (mobile preserved):
   - Blobbi visual reduced on desktop: size-72/size-80/size-96 ladder
     (was size-80/size-[28rem]/size-[32rem])
   - Stats crown arc spread widened on desktop: 90/140/180 degrees
     (was 80/120/160), with radius up to 220px (was 180px). This gives
     the stat indicators more breathing room on wide screens.
   - Stats crown margin: mb-4 sm:mb-8 (tighter on mobile, roomier on desktop)
   - Mobile stat sizing and spacing unchanged from previous pass.

3. Mobile/tablet bottom nav clearance:
   - New shared ROOM_BOTTOM_BAR_CLASS constant in room-layout.ts
   - On max-sidebar (below 900px), bottom bars add padding:
     calc(var(--bottom-nav-height) + env(safe-area-inset-bottom) + 1rem)
   - This pushes room controls above the app's fixed bottom navigation
     (Feed/Search/Notifications/Profile)
   - On desktop (sidebar:), normal pb-6 applies since there's no
     bottom nav bar.
   - All 6 rooms now use the shared class for consistent clearance.
2026-04-06 01:32:43 -03:00
filemon a2600d1caa Fix room layout proportions, responsive sizing, and visual composition
Addresses layout issues where the room felt cut off and controls were
disproportionate, especially on mobile.

Key changes:

RoomActionButton — responsive sizing:
  Mobile: size-14 circle, size-7 icons
  Desktop: size-20 circle, size-9 icons
  Tighter gap-1 and text-[10px] labels on mobile

ItemCarousel — responsive behavior:
  Mobile: focused item only + compact arrows, no prev/next previews
  Desktop: focused item + translucent side previews
  Smaller focused item (text-4xl mobile, text-5xl desktop)

BlobbiRoomHero — reduced visual footprint:
  Blobbi visual: tighter responsive scale (size-48 -> size-60 -> size-80)
  Stats crown: reduced margin (mb-6 sm:mb-10 vs mb-14)
  Stat indicators: size-14 sm:size-[4.5rem] (was size-[4.5rem] sm:size-20)
  Glow blur reduced on mobile (-m-16 vs -m-24)
  overflow-hidden to prevent hero from clipping

HomeRoom — unified bottom bar:
  Removed -mt-10 negative margin hack that caused visual clipping
  Photo, Carousel, and Companion now sit in one flex row with
  items-center alignment, forming a single cohesive bottom composition

All rooms — standardized bottom bar pattern:
  Consistent px-3 sm:px-6 pb-4 sm:pb-6 pt-1 spacing
  flex items-center justify-between gap-1 sm:gap-3 layout
  Spacer divs match button widths (w-14 sm:w-20) for balance

Room shell content area uses flex flex-col to ensure rooms
fill the available vertical space correctly.
2026-04-06 01:15:11 -03:00
filemon 0722d900a2 Polish room UI: single-focus carousel, unified action buttons, add Rest room
Major improvements to room layout and visual consistency:

- ItemCarousel: single-focus carousel showing one main item at center
  with translucent prev/next previews and left/right navigation arrows.
  Replaces the horizontal scroll rows in Kitchen, Care, and Home rooms.

- RoomActionButton: unified circular button component matching the
  original Photo and Companion button visual language (radial glow
  background, consistent size, hover lift, label beneath).
  Used in all rooms for consistent visual weight.

- BlobbiRestRoom: new dedicated bedroom for sleep/wake behavior,
  with a Moon/Sun toggle as a RoomActionButton on the bottom-right.
  Sleep/wake removed from Home room.

- Updated default room order: care -> kitchen -> home -> hatchery ->
  rest -> closet (looped in both directions).

- All room bottom bars now use consistent px-4 sm:px-8 pb-6 spacing
  with items-start justify-between layout for left/center/right zones.

- HatcheryRoom left/right buttons (Blobbis, Quests) upgraded to
  RoomActionButton with badge support.
2026-04-06 01:02:14 -03:00
filemon 918814371c Refactor Blobbi dashboard into room-based navigation system
Replace the tab/drawer layout with a room-based system where each room
represents a specific area of Blobbi interaction:
- Care (bathroom): hygiene tools, medicine, towel, shower
- Kitchen: food carousel, fridge modal
- Home: toys, music, sing, photo, companion, lamp/sleep
- Hatchery: hatch/evolve progress, quests sheet, Blobbis sheet
- Closet: placeholder for future wardrobe

Architecture:
- Room types, config, and navigation helpers in src/blobbi/rooms/lib/
- BlobbiRoomShell manages current room state and left/right navigation
- BlobbiRoomHero shared component for the Blobbi visual + stats crown
- Each room is a self-contained component receiving BlobbiRoomContext
- Default room order is data-driven (DEFAULT_ROOM_ORDER array) to
  support future per-user customization
- Navigation direction tracked for future animated transitions
- All existing hooks, mutations, and flows preserved unchanged
2026-04-06 00:46:04 -03:00
31 changed files with 2949 additions and 1331 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ditto",
"version": "2.6.0",
"version": "2.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.6.0",
"version": "2.6.1",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
@@ -10,14 +10,14 @@
* 4. Settings row — low emphasis toggle (not collapsible)
*
* Both main sections use lightweight Radix Collapsible wrappers.
* Collapsed headers still show summary info (progress / coins).
* Collapsed headers still show summary info (progress / XP).
*/
import {
Loader2,
XCircle,
AlertTriangle,
Coins,
Zap,
X,
Eye,
Scroll,
@@ -148,6 +148,8 @@ function MissionTypeLegend() {
interface DailyMissionsSectionProps {
profile: BlobbonautProfile | null;
updateProfileEvent: (event: NostrEvent) => void;
companion?: import('@/blobbi/core/lib/blobbi').BlobbiCompanion | null;
updateCompanionEvent?: (event: NostrEvent) => void;
availableStages?: ('egg' | 'baby' | 'adult')[];
disabled?: boolean;
defaultOpen?: boolean;
@@ -156,6 +158,8 @@ interface DailyMissionsSectionProps {
function DailyMissionsSection({
profile,
updateProfileEvent,
companion,
updateCompanionEvent,
availableStages,
disabled,
defaultOpen = true,
@@ -176,6 +180,8 @@ function DailyMissionsSection({
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
profile,
updateProfileEvent,
companion,
updateCompanionEvent,
);
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
@@ -194,7 +200,7 @@ function DailyMissionsSection({
<div className="flex items-center gap-2">
{/* Summary pill — always visible */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Coins className="size-3 shrink-0 text-amber-500 dark:text-amber-400" />
<Zap className="size-3 shrink-0 text-amber-500 dark:text-amber-400" />
<span className="tabular-nums">
{formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)}
</span>
@@ -215,7 +221,7 @@ function DailyMissionsSection({
missions={missions}
onClaimReward={(id) => claimReward({ missionId: id })}
onRerollMission={(id) => rerollMission({ missionId: id, availableStages })}
todayCoins={todayClaimedReward}
todayXp={todayClaimedReward}
disabled={disabled || isClaiming || isRerolling}
bonusAvailable={bonusAvailable}
bonusClaimed={bonusClaimed}
@@ -9,7 +9,7 @@
import { useState } from 'react';
import {
Check,
Coins,
Zap,
Gift,
Sparkles,
Egg,
@@ -43,7 +43,7 @@ interface DailyMissionsPanelProps {
missions: DailyMission[];
onClaimReward: (missionId: string) => void;
onRerollMission?: (missionId: string) => void;
todayCoins: number;
todayXp: number;
disabled?: boolean;
bonusAvailable?: boolean;
bonusClaimed?: boolean;
@@ -112,7 +112,7 @@ function BonusCard({ isAvailable, isClaimed, reward, onClaim, disabled, isExpand
</MissionDescription>
<div className="flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400">
<Coins className="size-3" />
<Zap className="size-3" />
+{formatCompactNumber(reward)}
</div>
@@ -124,7 +124,7 @@ function BonusCard({ isAvailable, isClaimed, reward, onClaim, disabled, isExpand
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white h-8 text-xs"
>
<Trophy className="size-3.5 mr-1.5" />
Claim Bonus {formatCompactNumber(reward)} Coins
Claim +{formatCompactNumber(reward)} XP
</Button>
)}
</ExpandableMissionCard>
@@ -147,7 +147,7 @@ function NoMissionsState() {
);
}
function AllClaimedState({ todayCoins }: { todayCoins: number }) {
function AllClaimedState({ todayXp }: { todayXp: number }) {
return (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Sparkles className="size-5 text-primary/60" />
@@ -156,7 +156,7 @@ function AllClaimedState({ todayCoins }: { todayCoins: number }) {
<p className="text-xs text-muted-foreground mt-0.5">
Earned{' '}
<span className="font-medium text-amber-600 dark:text-amber-400">
{formatCompactNumber(todayCoins)} coins
{formatCompactNumber(todayXp)} XP earned
</span>{' '}
come back tomorrow!
</p>
@@ -189,7 +189,7 @@ export function DailyMissionsPanel({
missions,
onClaimReward,
onRerollMission,
todayCoins,
todayXp,
disabled,
bonusAvailable = false,
bonusClaimed = false,
@@ -205,7 +205,7 @@ export function DailyMissionsPanel({
const allRegularClaimed = missions.every((m) => m.claimed);
const allDone = allRegularClaimed && bonusClaimed;
if (allDone) return <AllClaimedState todayCoins={todayCoins} />;
if (allDone) return <AllClaimedState todayXp={todayXp} />;
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
@@ -251,7 +251,7 @@ export function DailyMissionsPanel({
{/* Reward + reroll row */}
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
<Coins className="size-3" />
<Zap className="size-3" />
{formatCompactNumber(mission.reward)}
</span>
@@ -297,7 +297,7 @@ export function DailyMissionsPanel({
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white h-8 text-xs"
>
<Gift className="size-3.5 mr-1.5" />
Claim {formatCompactNumber(mission.reward)} Coins
Claim +{formatCompactNumber(mission.reward)} XP
</Button>
)}
</ExpandableMissionCard>
+127 -101
View File
@@ -2,23 +2,31 @@
* useClaimMissionReward - Hook for claiming daily mission rewards
*
* Handles:
* - Persisting coin rewards to kind 11125 Blobbonaut profile
* - Updating localStorage mission state
* - Awarding XP to the active companion (Kind 31124)
* - Persisting mission claimed state to profile content JSON (Kind 11125)
* - Updating localStorage optimistic cache
* - Idempotent claiming (prevents double-credit)
* - Optimistic cache updates
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { BlobbonautProfile, BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbonautTags,
KIND_BLOBBI_STATE,
} from '@/blobbi/core/lib/blobbi';
import {
mergeProfileContent,
missionToPersistedMission,
type PersistedDailyMissions,
} from '@/blobbi/core/lib/blobbonaut-content';
import {
type DailyMissionsState,
getTodayDateString,
@@ -40,20 +48,26 @@ export const BONUS_MISSION_ID = 'bonus_daily_complete';
export interface ClaimMissionResult {
missionId: string;
coinsEarned: number;
newTotalCoins: number;
xpEarned: number;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
// ─── Storage Utilities (local optimistic cache) ───────────────────────────────
function readMissionsState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
if (!stored) return null;
const parsed = JSON.parse(stored);
// Support legacy field name
if (parsed.totalCoinsEarned !== undefined && parsed.totalXpEarned === undefined) {
parsed.totalXpEarned = parsed.totalCoinsEarned;
delete parsed.totalCoinsEarned;
}
return parsed;
} catch {
return null;
}
@@ -72,16 +86,21 @@ function writeMissionsState(state: DailyMissionsState): void {
/**
* Hook to claim daily mission rewards.
*
* This hook persists coin rewards to the kind 11125 Blobbonaut profile event,
* ensuring rewards are stored on-chain rather than just in localStorage.
* Awards XP to the active companion (Kind 31124) and persists
* mission state to the profile content JSON (Kind 11125).
*
* @param currentProfile - The current Blobbonaut profile (required for coin updates)
* @param updateProfileEvent - Callback to update the profile in the query cache
* @param currentProfile - The current Blobbonaut profile
* @param updateProfileEvent - Optimistic cache update for profile
* @param currentCompanion - The active companion to award XP to
* @param updateCompanionEvent - Optimistic cache update for companion
*/
export function useClaimMissionReward(
currentProfile: BlobbonautProfile | null,
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void,
currentCompanion?: BlobbiCompanion | null,
updateCompanionEvent?: (event: import('@nostrify/nostrify').NostrEvent) => void,
) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
@@ -96,134 +115,141 @@ export function useClaimMissionReward(
throw new Error('Profile not found');
}
// Read current missions state from localStorage
// Read current missions state from localStorage (optimistic cache)
let missionsState = readMissionsState();
// Ensure we have valid state for today
if (needsDailyReset(missionsState)) {
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins);
const previousXp = missionsState?.totalXpEarned ?? 0;
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousXp);
}
let xpToAward = 0;
let updatedState: DailyMissionsState;
// Handle bonus mission claim
if (missionId === BONUS_MISSION_ID) {
// Check if bonus is available
if (!isBonusMissionAvailable(missionsState!)) {
throw new Error('Bonus mission not available yet');
}
// Check if already claimed
if (isBonusMissionClaimed(missionsState!)) {
throw new Error('Bonus reward already claimed');
}
const coinsToAdd = BONUS_MISSION_DEFINITION.reward;
const newTotalCoins = currentProfile.coins + coinsToAdd;
// Build updated tags with new coin balance
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
coins: newTotalCoins.toString(),
});
// Publish updated profile event
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
// Update the query cache
updateProfileEvent(event);
// Update localStorage to mark bonus as claimed
const updatedState: DailyMissionsState = {
xpToAward = BONUS_MISSION_DEFINITION.reward;
updatedState = {
...missionsState!,
bonusClaimed: true,
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
totalXpEarned: missionsState!.totalXpEarned + xpToAward,
};
} else {
// Handle regular mission claim
const mission = missionsState!.missions.find(m => m.id === missionId);
if (!mission) throw new Error('Mission not found');
if (mission.claimed) throw new Error('Reward already claimed');
if (!mission.completed) throw new Error('Mission not completed yet');
writeMissionsState(updatedState);
// Dispatch event for React components to re-render
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: { missionId, claimed: true, isBonus: true }
}));
return {
missionId,
coinsEarned: coinsToAdd,
newTotalCoins,
xpToAward = mission.reward;
updatedState = {
...missionsState!,
missions: missionsState!.missions.map(m =>
m.id === missionId ? { ...m, claimed: true } : m
),
totalXpEarned: missionsState!.totalXpEarned + xpToAward,
};
}
// Handle regular mission claim
const mission = missionsState!.missions.find(m => m.id === missionId);
if (!mission) {
throw new Error('Mission not found');
}
// ── 1. Persist mission state to profile content JSON (Kind 11125) ──
// Check if already claimed (idempotency check)
if (mission.claimed) {
throw new Error('Reward already claimed');
}
// Check if mission is completed
if (!mission.completed) {
throw new Error('Mission not completed yet');
}
const coinsToAdd = mission.reward;
const newTotalCoins = currentProfile.coins + coinsToAdd;
// Build updated tags with new coin balance
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
coins: newTotalCoins.toString(),
// Fetch fresh profile to avoid overwriting concurrent changes
const freshProfileEvent = await fetchFreshEvent(nostr, {
kinds: [KIND_BLOBBONAUT_PROFILE],
authors: [user.pubkey],
});
const existingContent = freshProfileEvent?.content ?? '';
const existingTags = freshProfileEvent?.tags ?? currentProfile.allTags;
// Publish updated profile event to kind 11125
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
// Update the query cache optimistically
updateProfileEvent(event);
// Now update localStorage to mark mission as claimed
const updatedMissions = missionsState!.missions.map(m =>
m.id === missionId ? { ...m, claimed: true } : m
);
const updatedState: DailyMissionsState = {
...missionsState!,
missions: updatedMissions,
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
// Build persisted daily missions
const persistedMissions: PersistedDailyMissions = {
date: updatedState.date,
missions: updatedState.missions.map(missionToPersistedMission),
bonusClaimed: updatedState.bonusClaimed ?? false,
rerollsRemaining: updatedState.rerollsRemaining ?? 3,
totalXpEarned: updatedState.totalXpEarned,
lastUpdatedAt: Date.now(),
};
const updatedContent = mergeProfileContent(existingContent, {
dailyMissions: persistedMissions,
});
// Publish updated profile (tags preserved, content updated)
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: updatedContent,
tags: existingTags,
prev: freshProfileEvent ?? undefined,
});
updateProfileEvent(profileEvent);
// ── 2. Award XP to the active companion (Kind 31124) ──
if (xpToAward > 0 && currentCompanion) {
try {
// Fetch fresh companion event
const freshCompanionEvent = await fetchFreshEvent(nostr, {
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': [currentCompanion.d],
});
if (freshCompanionEvent) {
const currentXp = parseInt(
freshCompanionEvent.tags.find(([t]) => t === 'experience')?.[1] ?? '0',
10,
);
const newXp = currentXp + xpToAward;
// Update the experience tag
const updatedTags = freshCompanionEvent.tags.map(tag =>
tag[0] === 'experience' ? ['experience', String(newXp)] : tag,
);
const companionEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: freshCompanionEvent.content,
tags: updatedTags,
prev: freshCompanionEvent,
});
updateCompanionEvent?.(companionEvent);
}
} catch (err) {
// XP award failure is non-fatal — mission claim still succeeds
console.warn('[useClaimMissionReward] Failed to award XP to companion:', err);
}
}
// ── 3. Update localStorage optimistic cache ──
writeMissionsState(updatedState);
// Dispatch event for React components to re-render
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: { missionId, claimed: true }
detail: { missionId, claimed: true, isBonus: missionId === BONUS_MISSION_ID }
}));
return {
missionId,
coinsEarned: coinsToAdd,
newTotalCoins,
};
return { missionId, xpEarned: xpToAward };
},
onSuccess: ({ coinsEarned }) => {
// Invalidate profile query to ensure fresh data
onSuccess: ({ xpEarned }) => {
if (user?.pubkey) {
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
}
// Show success toast
toast({
title: 'Reward Claimed!',
description: `You earned ${coinsEarned} coins.`,
description: `${currentCompanion?.name ?? 'Your Blobbi'} earned ${xpEarned} XP`,
});
},
onError: (error: Error) => {
+8 -8
View File
@@ -52,8 +52,8 @@ export interface UseDailyMissionsResult {
totalPotentialReward: number;
/** Total claimed reward for today */
todayClaimedReward: number;
/** Lifetime total coins earned from daily missions */
lifetimeCoinsEarned: number;
/** Lifetime total XP earned from daily missions */
lifetimeXpEarned: number;
/** Whether the bonus mission is available (all regular missions completed) */
bonusAvailable: boolean;
/** Whether the bonus mission has been claimed */
@@ -132,8 +132,8 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
const currentState = useMemo(() => {
// Check if we need to reset for a new day
if (needsDailyReset(state)) {
const previousCoins = state?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
const previousXp = state?.totalXpEarned ?? (state as unknown as { totalCoinsEarned?: number })?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousXp, availableStages);
// Persist the reset state (this will trigger version bump via setState)
writeMissionsState(newState);
return newState;
@@ -155,8 +155,8 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
// Force reset missions (for testing)
const forceReset = () => {
const previousCoins = state?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
const previousXp = state?.totalXpEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousXp, availableStages);
setState(newState);
};
@@ -181,7 +181,7 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
const baseTodayClaimedReward = getTodayClaimedReward(currentState);
const todayClaimedReward = baseTodayClaimedReward + (bonusClaimed ? bonusReward : 0);
const lifetimeCoinsEarned = currentState.totalCoinsEarned;
const lifetimeXpEarned = currentState.totalXpEarned;
return {
missions,
@@ -189,7 +189,7 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
allClaimed,
totalPotentialReward,
todayClaimedReward,
lifetimeCoinsEarned,
lifetimeXpEarned,
bonusAvailable,
bonusClaimed,
bonusReward,
+2 -2
View File
@@ -92,8 +92,8 @@ export function useRerollMission() {
// Ensure we have valid state for today
if (needsDailyReset(missionsState)) {
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins, availableStages);
const previousXp = missionsState?.totalXpEarned ?? (missionsState as unknown as { totalCoinsEarned?: number })?.totalCoinsEarned ?? 0;
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousXp, availableStages);
}
// Check if reroll is allowed
@@ -54,8 +54,9 @@ function ensureCurrentState(pubkey?: string): DailyMissionsState {
const current = readState();
if (needsDailyReset(current)) {
const previousCoins = current?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins);
// Support both legacy (totalCoinsEarned) and current (totalXpEarned) fields
const previousXp = current?.totalXpEarned ?? (current as unknown as { totalCoinsEarned?: number })?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousXp);
writeState(newState);
return newState;
}
+36 -35
View File
@@ -3,7 +3,7 @@
*
* This module defines the daily mission pool, selection logic, and types.
* Daily missions are separate from hatch/evolve missions and provide
* daily engagement loops with coin rewards.
* daily engagement loops with XP rewards applied to the active companion.
*/
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -40,7 +40,7 @@ export interface DailyMissionDefinition {
action: DailyMissionAction;
/** Number of times the action must be performed */
requiredCount: number;
/** Coin reward for completing this mission */
/** XP reward for completing this mission (applied to active companion) */
reward: number;
/** Selection weight (higher = more likely to be selected) */
weight: number;
@@ -61,15 +61,17 @@ export interface DailyMission extends DailyMissionDefinition {
}
/**
* Stored state for daily missions (persisted in localStorage)
* Stored state for daily missions.
* Source of truth is now Kind 11125 profile content JSON.
* localStorage is used only as a local optimistic cache.
*/
export interface DailyMissionsState {
/** The date string (YYYY-MM-DD) when these missions were generated */
date: string;
/** The selected missions for this day */
missions: DailyMission[];
/** Total coins earned from daily missions (lifetime) */
totalCoinsEarned: number;
/** Total XP earned from daily missions (lifetime) */
totalXpEarned: number;
/** Whether the bonus mission has been claimed today */
bonusClaimed?: boolean;
/** Number of rerolls remaining for today (resets daily, max 3) */
@@ -104,7 +106,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
description: 'Interact with your Blobbi 3 times',
action: 'interact',
requiredCount: 3,
reward: 30,
reward: 15,
weight: 10,
requiredStages: ['baby', 'adult'],
},
@@ -114,7 +116,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
description: 'Interact with your Blobbi 6 times',
action: 'interact',
requiredCount: 6,
reward: 50,
reward: 30,
weight: 8,
requiredStages: ['baby', 'adult'],
},
@@ -126,7 +128,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
description: 'Feed your Blobbi once',
action: 'feed',
requiredCount: 1,
reward: 25,
reward: 10,
weight: 10,
requiredStages: ['baby', 'adult'],
},
@@ -136,7 +138,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
description: 'Feed your Blobbi 2 times',
action: 'feed',
requiredCount: 2,
reward: 45,
reward: 20,
weight: 8,
requiredStages: ['baby', 'adult'],
},
@@ -146,7 +148,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
description: 'Feed your Blobbi 3 times',
action: 'feed',
requiredCount: 3,
reward: 60,
reward: 35,
weight: 5,
requiredStages: ['baby', 'adult'],
},
@@ -158,7 +160,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
description: 'Put your Blobbi to sleep',
action: 'sleep',
requiredCount: 1,
reward: 30,
reward: 15,
weight: 6,
requiredStages: ['baby', 'adult'],
},
@@ -170,7 +172,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
description: 'Take a polaroid photo of your Blobbi',
action: 'take_photo',
requiredCount: 1,
reward: 55,
reward: 25,
weight: 4,
requiredStages: ['baby', 'adult'],
},
@@ -180,7 +182,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
description: 'Take 2 photos of your Blobbi',
action: 'take_photo',
requiredCount: 2,
reward: 70,
reward: 40,
weight: 2,
requiredStages: ['baby', 'adult'],
},
@@ -197,7 +199,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
description: 'Clean your Blobbi once',
action: 'clean',
requiredCount: 1,
reward: 25,
reward: 10,
weight: 10,
requiredStages: ['egg', 'baby', 'adult'],
},
@@ -207,7 +209,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
description: 'Clean your Blobbi 2 times',
action: 'clean',
requiredCount: 2,
reward: 45,
reward: 20,
weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
@@ -219,7 +221,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
description: 'Sing a song to your Blobbi',
action: 'sing',
requiredCount: 1,
reward: 30,
reward: 15,
weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
@@ -229,7 +231,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
description: 'Sing 2 songs to your Blobbi',
action: 'sing',
requiredCount: 2,
reward: 50,
reward: 25,
weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
@@ -241,7 +243,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
description: 'Play a song for your Blobbi',
action: 'play_music',
requiredCount: 1,
reward: 30,
reward: 15,
weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
@@ -251,20 +253,19 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
description: 'Play 2 songs for your Blobbi',
action: 'play_music',
requiredCount: 2,
reward: 50,
reward: 25,
weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
// ─── Medicine Missions (All stages) ────────────────────────────────────────
// Medicine rewards are higher since medicine costs coins to use
{
id: 'medicine_1',
title: 'Health Check',
description: 'Give medicine to your Blobbi',
action: 'medicine',
requiredCount: 1,
reward: 60,
reward: 20,
weight: 5,
requiredStages: ['egg', 'baby', 'adult'],
},
@@ -274,7 +275,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
description: 'Give medicine to your Blobbi 2 times',
action: 'medicine',
requiredCount: 2,
reward: 70,
reward: 35,
weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
@@ -405,14 +406,14 @@ export function createMissionFromDefinition(def: DailyMissionDefinition): DailyM
export function createDailyMissionsState(
dateString: string,
pubkey?: string,
previousTotalCoins: number = 0,
previousTotalXp: number = 0,
availableStages?: BlobbiStage[]
): DailyMissionsState {
const definitions = selectDailyMissions(3, dateString, pubkey, availableStages);
return {
date: dateString,
missions: definitions.map(createMissionFromDefinition),
totalCoinsEarned: previousTotalCoins,
totalXpEarned: previousTotalXp,
rerollsRemaining: MAX_DAILY_REROLLS,
};
}
@@ -461,8 +462,8 @@ export function updateMissionProgress(
export function claimMissionReward(
state: DailyMissionsState,
missionId: string
): { state: DailyMissionsState; coinsEarned: number } {
let coinsEarned = 0;
): { state: DailyMissionsState; xpEarned: number } {
let xpEarned = 0;
const updatedMissions = state.missions.map((mission) => {
if (mission.id !== missionId) return mission;
@@ -470,7 +471,7 @@ export function claimMissionReward(
// Can only claim if completed and not yet claimed
if (!mission.completed || mission.claimed) return mission;
coinsEarned = mission.reward;
xpEarned = mission.reward;
return {
...mission,
claimed: true,
@@ -481,9 +482,9 @@ export function claimMissionReward(
state: {
...state,
missions: updatedMissions,
totalCoinsEarned: state.totalCoinsEarned + coinsEarned,
totalXpEarned: state.totalXpEarned + xpEarned,
},
coinsEarned,
xpEarned,
};
}
@@ -526,10 +527,10 @@ export function areAllMissionsClaimed(state: DailyMissionsState): boolean {
export const BONUS_MISSION_DEFINITION: DailyMissionDefinition = {
id: 'bonus_daily_complete',
title: 'Daily Champion',
description: 'Complete all daily missions to claim this bonus reward',
description: 'Complete all daily missions to claim this bonus XP',
action: 'interact', // Not actually used - bonus is auto-completed
requiredCount: 1,
reward: 80,
reward: 50,
weight: 0, // Not part of random selection
};
@@ -553,19 +554,19 @@ export function isBonusMissionClaimed(state: DailyMissionsState): boolean {
*/
export function claimBonusMissionReward(
state: DailyMissionsState
): { state: DailyMissionsState; coinsEarned: number } {
): { state: DailyMissionsState; xpEarned: number } {
// Can only claim if bonus is available and not yet claimed
if (!isBonusMissionAvailable(state) || isBonusMissionClaimed(state)) {
return { state, coinsEarned: 0 };
return { state, xpEarned: 0 };
}
return {
state: {
...state,
bonusClaimed: true,
totalCoinsEarned: state.totalCoinsEarned + BONUS_MISSION_DEFINITION.reward,
totalXpEarned: state.totalXpEarned + BONUS_MISSION_DEFINITION.reward,
},
coinsEarned: BONUS_MISSION_DEFINITION.reward,
xpEarned: BONUS_MISSION_DEFINITION.reward,
};
}
+1 -1
View File
@@ -188,7 +188,7 @@ export function useBlobbiMigration() {
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
content: profile.event.content,
tags: profileTags,
});
+8 -1
View File
@@ -3,6 +3,7 @@ import { bytesToHex } from '@noble/hashes/utils';
import type { NostrEvent } from '@nostrify/nostrify';
import { validateAndRepairBlobbiTags } from './blobbi-tag-schema';
import { parseProfileContent } from './blobbonaut-content';
// ─── Constants ────────────────────────────────────────────────────────────────
@@ -312,7 +313,7 @@ export interface BlobbonautProfile {
name: string | undefined;
/** List of owned Blobbi d-tags */
has: string[];
/** In-game currency balance */
/** In-game currency balance (legacy — daily missions now use XP) */
coins: number;
/** Petting level (interaction counter) */
pettingLevel: number;
@@ -320,6 +321,8 @@ export interface BlobbonautProfile {
storage: StorageItem[];
/** All tags preserved for republishing */
allTags: string[][];
/** Parsed content JSON (daily missions, future fields). Empty object if legacy/empty content. */
content: import('./blobbonaut-content').BlobbonautProfileContent;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
@@ -971,6 +974,9 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
const pettingLevelValue = parseNumericTag(tags, 'pettingLevel')
?? parseNumericTag(tags, 'petting_level')
?? 0;
// Parse structured content JSON (daily missions, future fields)
const parsedContent = parseProfileContent(event.content);
return {
event,
@@ -984,6 +990,7 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
pettingLevel: pettingLevelValue,
storage: parseStorageTags(tags),
allTags: tags,
content: parsedContent,
};
}
+214
View File
@@ -0,0 +1,214 @@
// src/blobbi/core/lib/blobbonaut-content.ts
/**
* Blobbonaut Profile Content JSON — Type definitions, parsing, and serialization.
*
* Kind 11125 previously used empty string content with all data in tags.
* We're now introducing structured JSON content, starting with daily missions.
*
* Design principles:
* - Content is always valid JSON (or empty string for legacy)
* - Unknown fields are preserved during read-modify-write
* - Missing fields default gracefully (no crashes on partial data)
* - Each top-level key is independently versioned via the field's own shape
*/
import type { DailyMission } from '@/blobbi/actions/lib/daily-missions';
// ─── Daily Missions Persisted Shape ───────────────────────────────────────────
/**
* The daily missions state as persisted in profile content JSON.
* This replaces the localStorage-only `DailyMissionsState`.
*/
export interface PersistedDailyMissions {
/** The date these missions were generated (YYYY-MM-DD) */
date: string;
/** The missions for this day, including progress */
missions: PersistedDailyMission[];
/** Whether the bonus mission has been claimed today */
bonusClaimed: boolean;
/** Number of rerolls remaining for today (resets daily) */
rerollsRemaining: number;
/** Total XP earned from daily missions (lifetime, across all Blobbis) */
totalXpEarned: number;
/** Timestamp (ms) when this was last modified */
lastUpdatedAt: number;
}
/**
* A single daily mission as persisted.
* Mirrors DailyMission but explicitly typed for serialization.
*/
export interface PersistedDailyMission {
id: string;
title: string;
description: string;
action: string;
requiredCount: number;
/** XP reward (was previously coins) */
reward: number;
weight: number;
requiredStages?: string[];
currentCount: number;
completed: boolean;
claimed: boolean;
}
// ─── Full Profile Content Shape ───────────────────────────────────────────────
/**
* The full structured content of a Kind 11125 Blobbonaut Profile event.
*
* New top-level fields can be added here as the system grows.
* Unknown fields from the raw JSON are preserved in `_raw` during
* read-modify-write to avoid losing data from future versions.
*/
export interface BlobbonautProfileContent {
/** Daily missions state. Undefined if never migrated. */
dailyMissions?: PersistedDailyMissions;
}
/**
* Internal representation that also carries unknown fields for safe merging.
*/
interface RawProfileContent extends BlobbonautProfileContent {
/** Captures any fields we don't recognize, for forward compatibility */
[key: string]: unknown;
}
// ─── Parsing ──────────────────────────────────────────────────────────────────
/**
* Parse the content field of a Kind 11125 event into structured data.
*
* - Empty string or invalid JSON returns empty object (no dailyMissions).
* - Malformed dailyMissions field is silently dropped.
* - Unknown top-level fields are preserved in the return value.
*/
export function parseProfileContent(content: string): RawProfileContent {
if (!content || content.trim() === '') {
return {};
}
try {
const raw = JSON.parse(content);
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
return {};
}
const result: RawProfileContent = { ...raw };
// Validate dailyMissions shape if present
if (raw.dailyMissions) {
const dm = raw.dailyMissions;
if (
typeof dm === 'object' &&
dm !== null &&
typeof dm.date === 'string' &&
Array.isArray(dm.missions)
) {
result.dailyMissions = {
date: dm.date,
missions: dm.missions.filter(isValidPersistedMission),
bonusClaimed: dm.bonusClaimed === true,
rerollsRemaining: typeof dm.rerollsRemaining === 'number' ? dm.rerollsRemaining : 3,
totalXpEarned: typeof dm.totalXpEarned === 'number' ? dm.totalXpEarned : 0,
lastUpdatedAt: typeof dm.lastUpdatedAt === 'number' ? dm.lastUpdatedAt : 0,
};
} else {
// Malformed — drop it
delete result.dailyMissions;
}
}
return result;
} catch {
return {};
}
}
/**
* Validate a single persisted mission has the minimum required fields.
*/
function isValidPersistedMission(m: unknown): m is PersistedDailyMission {
if (typeof m !== 'object' || m === null) return false;
const obj = m as Record<string, unknown>;
return (
typeof obj.id === 'string' &&
typeof obj.action === 'string' &&
typeof obj.requiredCount === 'number' &&
typeof obj.reward === 'number' &&
typeof obj.currentCount === 'number' &&
typeof obj.completed === 'boolean' &&
typeof obj.claimed === 'boolean'
);
}
// ─── Serialization ────────────────────────────────────────────────────────────
/**
* Serialize profile content to a JSON string for the event content field.
*
* Performs a read-modify-write merge:
* 1. Parse the existing content to get all current fields (including unknown ones)
* 2. Apply the updates on top
* 3. Serialize back to JSON
*
* This ensures we never lose unrelated fields when updating just dailyMissions.
*/
export function mergeProfileContent(
existingContent: string,
updates: Partial<BlobbonautProfileContent>,
): string {
const existing = parseProfileContent(existingContent);
// Merge updates on top of existing
const merged: RawProfileContent = {
...existing,
...updates,
};
return JSON.stringify(merged);
}
// ─── Conversion helpers ───────────────────────────────────────────────────────
/**
* Convert a DailyMission (from the runtime type) to persisted form.
* These are nearly identical but this keeps the boundary explicit.
*/
export function missionToPersistedMission(m: DailyMission): PersistedDailyMission {
return {
id: m.id,
title: m.title,
description: m.description,
action: m.action,
requiredCount: m.requiredCount,
reward: m.reward,
weight: m.weight,
requiredStages: m.requiredStages,
currentCount: m.currentCount,
completed: m.completed,
claimed: m.claimed,
};
}
/**
* Convert a PersistedDailyMission back to the runtime DailyMission type.
*/
export function persistedMissionToMission(p: PersistedDailyMission): DailyMission {
return {
id: p.id,
title: p.title ?? p.id,
description: p.description ?? '',
action: p.action as DailyMission['action'],
requiredCount: p.requiredCount,
reward: p.reward,
weight: p.weight ?? 1,
requiredStages: p.requiredStages as DailyMission['requiredStages'],
currentCount: p.currentCount,
completed: p.completed,
claimed: p.claimed,
};
}
@@ -300,7 +300,7 @@ export function BlobbiHatchingCeremony({
const updatedProfileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
content: currentProfile?.event.content ?? '',
tags: updatedTags,
});
@@ -499,7 +499,7 @@ export function BlobbiHatchingCeremony({
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
content: currentProfile.event.content,
tags: updatedTags,
});
updateProfileEvent(profileEvent);
@@ -376,7 +376,7 @@ export function useBlobbiOnboarding({
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
content: profile.event.content,
tags: updatedTags,
});
@@ -474,7 +474,7 @@ export function useBlobbiOnboarding({
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
content: profile.event.content,
tags: updatedProfileTags,
});
@@ -0,0 +1,137 @@
// src/blobbi/rooms/components/BlobbiCareRoom.tsx
/**
* BlobbiCareRoom — Hygiene, care, and medicine room.
*
* Side actions depend on the currently focused carousel item:
* - Hygiene focused: Towel (left) + Shower (right)
* - Medicine focused: Treat (left) + spacer (right)
*
* Both left and right slots always render the same fixed width
* so the bottom bar never shifts when switching item types.
*/
import { useMemo, useState, useCallback } from 'react';
import { ShowerHead, Candy } from 'lucide-react';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { BlobbiRoomHero } from './BlobbiRoomHero';
import { RoomActionButton } from './RoomActionButton';
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
interface BlobbiCareRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) {
const {
isUsingItem,
usingItemId,
handleUseItemFromTab,
isPublishing,
actionInProgress,
isActiveFloatingCompanion,
} = ctx;
const hygieneItems = useMemo(() =>
getLiveShopItems().filter(i => i.type === 'hygiene'),
[]);
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
const towelItem = hygieneItems.find(i => i.id === 'hyg_towel');
// Carousel: hygiene (except towel) + medicine, each tagged with meta
const carouselEntries = useMemo<CarouselEntry[]>(() => {
const hygiene = getLiveShopItems()
.filter(i => i.type === 'hygiene' && i.id !== 'hyg_towel')
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name, meta: 'hygiene' }));
const medicine = getLiveShopItems()
.filter(i => i.type === 'medicine')
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name, meta: 'medicine' }));
return [...hygiene, ...medicine];
}, []);
// Track the type of the currently focused carousel item
const [focusedMeta, setFocusedMeta] = useState<string>(
carouselEntries[0]?.meta ?? 'hygiene',
);
const handleFocusChange = useCallback((entry: CarouselEntry) => {
setFocusedMeta(entry.meta ?? 'hygiene');
}, []);
const isHygieneFocused = focusedMeta === 'hygiene';
return (
<div className="flex flex-col flex-1 min-h-0">
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{!isActiveFloatingCompanion && (
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Left slot — always same width (button or spacer) */}
{isHygieneFocused ? (
towelItem ? (
<RoomActionButton
icon={<span className="text-2xl sm:text-3xl">{towelItem.icon}</span>}
label="Towel"
color="text-cyan-500"
glowHex="#06b6d4"
onClick={() => handleUseItemFromTab(towelItem.id)}
disabled={isDisabled}
loading={isUsingItem && usingItemId === towelItem.id}
/>
) : (
<div className="w-14 sm:w-20 shrink-0" />
)
) : (
<RoomActionButton
icon={<Candy className="size-7 sm:size-9" />}
label="Treat"
color="text-pink-400"
glowHex="#f472b6"
onClick={() => {
// Comfort treat — use a small food item as a reward after medicine
const treat = getLiveShopItems().find(i => i.type === 'food');
if (treat) handleUseItemFromTab(treat.id);
}}
disabled={isDisabled}
/>
)}
{/* Center carousel */}
<div className="flex-1 min-w-0 flex justify-center">
<ItemCarousel
items={carouselEntries}
onUse={handleUseItemFromTab}
activeItemId={isUsingItem ? usingItemId : null}
disabled={isDisabled}
onFocusChange={handleFocusChange}
/>
</div>
{/* Right slot — always same width (button or spacer) */}
{isHygieneFocused ? (
<RoomActionButton
icon={<ShowerHead className="size-7 sm:size-9" />}
label="Shower"
color="text-blue-500"
glowHex="#3b82f6"
onClick={() => {
const shampoo = hygieneItems.find(i => i.id === 'hyg_shampoo');
if (shampoo) handleUseItemFromTab(shampoo.id);
}}
disabled={isDisabled}
/>
) : (
<div className="w-14 sm:w-20 shrink-0" />
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,37 @@
// src/blobbi/rooms/components/BlobbiClosetRoom.tsx
/**
* BlobbiClosetRoom — Placeholder room for wardrobe / accessories.
*
* Uses the same bottom bar structure as other rooms for visual consistency,
* with a centered placeholder message.
*/
import { Shirt } from 'lucide-react';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { BlobbiRoomHero } from './BlobbiRoomHero';
interface BlobbiClosetRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
export function BlobbiClosetRoom({ ctx }: BlobbiClosetRoomProps) {
return (
<div className="flex flex-col flex-1 min-h-0">
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* Bottom bar — same structure as other rooms */}
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-center gap-2 py-1">
<Shirt className="size-5 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground/40 font-medium">
Closet coming soon
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,542 @@
// src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx
/**
* BlobbiHatcheryRoom — Incubation / evolution / progression room.
*
* Layout:
* - BlobbiRoomHero (Blobbi visual + stats)
* - Bottom center: main start/stop hatching or evolution button
* - Bottom right: quests/tasks button
* - Bottom left: Blobbis list/selector button
*
* Reuses existing hatch/evolve/missions logic from BlobbiPage.
*/
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import {
Loader2, Sparkles, Egg, Target, Check, ListTodo,
Wrench, Droplets, Heart, Zap, Moon, Camera, Music, Mic,
Pill, Utensils, Plus, Footprints, ExternalLink, Theater,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { openUrl } from '@/lib/downloadFile';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { isLocalhostDev } from '@/blobbi/dev';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { BlobbiRoomHero } from './BlobbiRoomHero';
import { RoomActionButton } from './RoomActionButton';
// ─── Helper: companionNeedsCare (reused from BlobbiPage) ──────────────────────
const CARE_THRESHOLD = 40;
function companionNeedsCare(companion: { stats: { hunger?: number; happiness?: number; hygiene?: number; health?: number } }): 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)
);
}
// ─── Props ────────────────────────────────────────────────────────────────────
interface BlobbiHatcheryRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiHatcheryRoom({ ctx }: BlobbiHatcheryRoomProps) {
const {
companion,
companions,
selectedD,
profile,
isEgg,
isBaby,
isIncubating,
isEvolvingState,
canStartIncubation,
canStartEvolution,
isStartingIncubation,
isStartingEvolution,
isStoppingIncubation,
isStoppingEvolution,
isHatching,
isEvolving,
hatchTasks,
evolveTasks,
onStartIncubation,
onStartEvolution,
onStopIncubation,
onStopEvolution,
onEvolve,
setShowPostModal,
setShowHatchCeremony,
isActiveFloatingCompanion,
// Blobbi selector
onSelectBlobbi,
blobbiNaddr,
// Adoption
setShowAdoptionFlow,
// Daily missions
dailyMissions,
onClaimReward,
isClaimingReward,
// DEV
setShowDevEditor,
setShowEmotionPanel,
} = ctx;
const navigate = useNavigate();
// Side panels
const [showQuestsPanel, setShowQuestsPanel] = useState(false);
const [showBlobbisPanel, setShowBlobbisPanel] = useState(false);
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
const tasks = isIncubating ? hatchTasks.tasks : evolveTasks.tasks;
const allCompleted = isIncubating ? hatchTasks.allCompleted : evolveTasks.allCompleted;
const isTasksLoading = isIncubating ? hatchTasks.isLoading : evolveTasks.isLoading;
const completedCount = tasks.filter(t => t.completed).length;
const totalCount = tasks.length;
const { missions } = dailyMissions;
return (
<div className="flex flex-col flex-1 min-h-0">
{/* ── Hero ── */}
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* ── Bottom Action Bar ── */}
{!isActiveFloatingCompanion && (
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Left — Blobbis selector */}
<RoomActionButton
icon={<Egg className="size-7 sm:size-9" />}
label="Blobbis"
color="text-primary"
glowHex="var(--primary)"
onClick={() => setShowBlobbisPanel(true)}
badge={companions.length > 1 ? (
<span className="size-4 sm:size-5 rounded-full bg-primary text-[9px] sm:text-[10px] text-primary-foreground font-bold flex items-center justify-center">
{companions.length}
</span>
) : undefined}
/>
{/* Center — Main hatch/evolve action */}
<div className="flex-1 flex flex-col items-center justify-center gap-1.5">
{/* Active process: Hatch/Evolve CTA or progress */}
{hasActiveProcess && allCompleted && !isTasksLoading && (
<button
onClick={isIncubating ? () => setShowHatchCeremony(true) : onEvolve}
disabled={isProcessBusy}
className={cn(
'flex items-center justify-center gap-2 px-8 py-3 rounded-full text-white font-semibold transition-all duration-300',
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
isProcessBusy && 'opacity-50 pointer-events-none',
)}
style={{
background: isIncubating
? 'linear-gradient(135deg, #0ea5e9, #8b5cf6)'
: 'linear-gradient(135deg, #8b5cf6, #ec4899)',
}}
>
{(isHatching || isEvolving) ? (
<Loader2 className="size-5 animate-spin" />
) : (
<span className="text-lg">{isIncubating ? '\uD83D\uDC23' : '\u2728'}</span>
)}
<span>{(isHatching || isEvolving) ? (isIncubating ? 'Hatching...' : 'Evolving...') : (isIncubating ? 'Hatch!' : 'Evolve!')}</span>
</button>
)}
{hasActiveProcess && !allCompleted && !isTasksLoading && (
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="size-4 text-primary" />
<span className="font-medium">{isIncubating ? 'Hatching' : 'Evolving'}</span>
<span className="text-xs tabular-nums">{completedCount}/{totalCount}</span>
</div>
{/* Progress bar */}
<div className="w-40 h-1.5 rounded-full bg-muted/30 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%`,
background: isIncubating
? 'linear-gradient(90deg, #0ea5e9, #8b5cf6)'
: 'linear-gradient(90deg, #8b5cf6, #ec4899)',
}}
/>
</div>
</div>
)}
{hasActiveProcess && isTasksLoading && (
<Loader2 className="size-5 animate-spin text-muted-foreground" />
)}
{/* No active process — show start button */}
{!hasActiveProcess && (canStartIncubation || canStartEvolution) && (
<button
onClick={() => canStartIncubation ? onStartIncubation('start') : onStartEvolution()}
disabled={isStartingIncubation || isStartingEvolution}
className={cn(
'flex items-center justify-center gap-2 px-8 py-3 rounded-full text-white font-semibold transition-all duration-300',
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
(isStartingIncubation || isStartingEvolution) && 'opacity-50 pointer-events-none',
)}
style={{
background: canStartIncubation
? 'linear-gradient(135deg, #0ea5e9, #8b5cf6)'
: 'linear-gradient(135deg, #8b5cf6, #ec4899)',
}}
>
{(isStartingIncubation || isStartingEvolution) ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Sparkles className="size-5" />
)}
<span>{canStartIncubation ? 'Begin Hatching' : 'Begin Evolution'}</span>
</button>
)}
{!hasActiveProcess && !canStartIncubation && !canStartEvolution && (
<p className="text-xs text-muted-foreground/50">No journey available</p>
)}
{/* Stop process link */}
{hasActiveProcess && !isTasksLoading && (
<button
onClick={isIncubating ? onStopIncubation : onStopEvolution}
disabled={isProcessBusy}
className="text-[11px] text-muted-foreground/40 hover:text-destructive/60 transition-colors"
>
{(isStoppingIncubation || isStoppingEvolution) ? 'Stopping...' : `Stop ${isIncubating ? 'incubation' : 'evolution'}`}
</button>
)}
</div>
{/* Right — Quests/Tasks */}
<RoomActionButton
icon={<ListTodo className="size-7 sm:size-9" />}
label="Quests"
color="text-amber-500"
glowHex="#f59e0b"
onClick={() => setShowQuestsPanel(true)}
badge={hasActiveProcess && totalCount - completedCount > 0 ? (
<span className="size-4 sm:size-5 rounded-full bg-amber-500 text-[9px] sm:text-[10px] text-white font-bold flex items-center justify-center">
{totalCount - completedCount}
</span>
) : undefined}
/>
</div>
</div>
)}
{/* ── Quests Sheet ── */}
<Sheet open={showQuestsPanel} onOpenChange={setShowQuestsPanel}>
<SheetContent side="right" className="w-80 sm:w-96 p-0">
<SheetHeader className="px-4 pt-4 pb-3 border-b">
<SheetTitle className="flex items-center gap-2 text-base">
<Target className="size-4" />
Quests
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-4rem)]">
<div className="p-4 space-y-4">
{/* Journey tasks */}
{hasActiveProcess && (
<div className="space-y-1">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
{isIncubating ? 'Hatching Journey' : 'Evolution Journey'}
</h3>
{isTasksLoading && (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
{!isTasksLoading && tasks.map(task => {
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate': navigate(task.actionTarget); setShowQuestsPanel(false); break;
case 'external_link': openUrl(task.actionTarget); break;
case 'open_modal': if (task.actionTarget === 'blobbi_post') { setShowPostModal(true); setShowQuestsPanel(false); } break;
}
};
const isActionable = !task.completed && !!task.action && !!task.actionTarget;
return (
<button
key={task.id}
onClick={isActionable ? handleAction : undefined}
disabled={!isActionable}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl transition-all text-left',
isActionable && 'hover:bg-accent/50 active:scale-[0.98] cursor-pointer',
!isActionable && 'cursor-default',
)}
>
<QuestTaskIcon taskId={task.id} completed={task.completed} />
<div className="flex-1 min-w-0">
<p className={cn('text-sm font-medium leading-tight', task.completed && 'text-muted-foreground line-through')}>{task.name}</p>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5 line-clamp-1">{task.description}</p>
</div>
{task.required > 1 && !task.completed && (
<span className="text-[10px] tabular-nums font-medium text-muted-foreground shrink-0">{task.current}/{task.required}</span>
)}
</button>
);
})}
</div>
)}
{!hasActiveProcess && (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Sparkles className="size-6 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground">Start a journey to unlock tasks</p>
</div>
)}
{/* Daily Bounties */}
<div className="space-y-1">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Daily Bounties
</h3>
{dailyMissions.noMissionsAvailable && (
<div className="flex flex-col items-center gap-2 py-4 text-center">
<Egg className="size-5 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground">Hatch your Blobbi to unlock bounties</p>
</div>
)}
{!dailyMissions.noMissionsAvailable && missions.map(mission => {
const canClaim = mission.completed && !mission.claimed;
return (
<div
key={mission.id}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl transition-all',
canClaim && 'bg-amber-500/[0.06]',
)}
>
<DailyMissionIcon action={mission.action} claimed={mission.claimed} canClaim={canClaim} />
<div className="flex-1 min-w-0">
<p className={cn('text-sm font-medium leading-tight', mission.claimed && 'text-muted-foreground line-through')}>{mission.title}</p>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5">{mission.description}</p>
</div>
{!mission.claimed && (
<span className="text-[10px] tabular-nums font-medium text-muted-foreground shrink-0">{mission.currentCount}/{mission.requiredCount}</span>
)}
{canClaim && (
<button
onClick={() => onClaimReward(mission.id)}
disabled={isClaimingReward}
className="shrink-0 text-xs font-semibold text-amber-600 dark:text-amber-400 hover:underline"
>
Claim
</button>
)}
</div>
);
})}
{/* Bonus row */}
{!dailyMissions.noMissionsAvailable && dailyMissions.bonusAvailable && !dailyMissions.bonusClaimed && (
<div className="w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl bg-amber-500/[0.06]">
<div className="size-8 rounded-full bg-amber-500/15 flex items-center justify-center shrink-0">
<Sparkles className="size-4 text-amber-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-tight">Daily Champion</p>
<p className="text-[10px] text-muted-foreground">All missions complete!</p>
</div>
<button
onClick={() => onClaimReward('bonus_daily_complete')}
disabled={isClaimingReward}
className="shrink-0 text-xs font-semibold text-amber-600 dark:text-amber-400 hover:underline"
>
Claim
</button>
</div>
)}
</div>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
{/* ── Blobbis Sheet ── */}
<Sheet open={showBlobbisPanel} onOpenChange={setShowBlobbisPanel}>
<SheetContent side="left" className="w-80 sm:w-96 p-0">
<SheetHeader className="px-4 pt-4 pb-3 border-b">
<SheetTitle className="flex items-center gap-2 text-base">
<Egg className="size-4" />
Your Blobbis
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-4rem)]">
<div className="p-4">
{/* Blobbi grid */}
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 py-3">
{companions.map((c) => {
const isSelected = c.d === selectedD;
const isCompanion = c.d === profile?.currentCompanion;
return (
<button
key={c.d}
onClick={() => { onSelectBlobbi(c.d); setShowBlobbisPanel(false); }}
className={cn(
'flex flex-col items-center gap-1 transition-all duration-200',
'hover:-translate-y-1 hover:scale-105 active:scale-95',
)}
>
<div className="relative">
<div className={cn(
'rounded-full p-1 transition-all',
isSelected ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : '',
)}>
<BlobbiStageVisual companion={c} size="sm" />
</div>
{isCompanion && (
<div className="absolute -bottom-0.5 -right-0.5 size-5 rounded-full bg-background ring-2 ring-background flex items-center justify-center">
<Footprints className="size-3 text-emerald-500" />
</div>
)}
{companionNeedsCare(c) && !isCompanion && (
<div className="absolute -top-0.5 -right-0.5 size-4 rounded-full bg-amber-500 flex items-center justify-center">
<span className="text-[8px] text-white font-bold">!</span>
</div>
)}
</div>
{c.stage !== 'egg' && (
<span className={cn(
'text-[11px] font-medium max-w-[4.5rem] truncate',
isSelected ? 'text-foreground' : 'text-muted-foreground',
)}>
{c.name}
</span>
)}
</button>
);
})}
{/* Adopt + button */}
<button
onClick={() => { setShowBlobbisPanel(false); setShowAdoptionFlow(true); }}
className="flex flex-col items-center gap-1 transition-all duration-200 hover:-translate-y-1 hover:scale-105 active:scale-95"
>
<div className="size-14 rounded-full flex items-center justify-center" style={{
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, currentColor 10%, transparent), color-mix(in srgb, currentColor 3%, transparent) 70%)',
}}>
<Plus className="size-6 text-muted-foreground/60" />
</div>
<span className="text-[11px] font-medium text-muted-foreground/60">Adopt</span>
</button>
</div>
{/* Quick actions row */}
<div className="flex items-center justify-center gap-6 pt-3 border-t mt-3">
<Link
to={`/${blobbiNaddr}`}
onClick={() => setShowBlobbisPanel(false)}
className="flex flex-col items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="size-5" />
<span className="text-[10px]">View</span>
</Link>
{/* DEV tools */}
{isLocalhostDev() && (
<>
{companion.stage !== 'adult' && (
<button
onClick={() => { setShowBlobbisPanel(false); if (isEgg) { setShowHatchCeremony(true); } else { onEvolve(); } }}
disabled={isHatching || isEvolving}
className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors disabled:opacity-40"
>
<Sparkles className="size-5" />
<span className="text-[10px]">{companion.stage === 'egg' ? 'Hatch' : 'Evolve'}</span>
</button>
)}
<button onClick={() => { setShowBlobbisPanel(false); setShowDevEditor(true); }} className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors">
<Wrench className="size-5" />
<span className="text-[10px]">Editor</span>
</button>
<button onClick={() => { setShowBlobbisPanel(false); setShowEmotionPanel(true); }} className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors">
<Theater className="size-5" />
<span className="text-[10px]">Emote</span>
</button>
</>
)}
</div>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</div>
);
}
// ─── Quest task icon (reused from BlobbiPage) ─────────────────────────────────
function QuestTaskIcon({ taskId, completed }: { taskId: string; completed: boolean }) {
const iconClass = 'size-4';
const icon = (() => {
switch (taskId) {
case 'create_themes': return <Sparkles className={iconClass} />;
case 'color_moments': return <Droplets className={iconClass} />;
case 'create_posts': return <Target className={iconClass} />;
case 'interactions': return <Heart className={iconClass} />;
case 'edit_profile': return <Wrench className={iconClass} />;
case 'maintain_stats': return <Zap className={iconClass} />;
default: return <Target className={iconClass} />;
}
})();
return (
<div className={cn(
'size-8 rounded-full flex items-center justify-center shrink-0',
completed ? 'bg-emerald-500/15 text-emerald-500' : 'bg-muted/60 text-muted-foreground',
)}>
{completed ? <Check className="size-4" /> : icon}
</div>
);
}
// ─── Daily mission icon (reused from BlobbiPage) ──────────────────────────────
function DailyMissionIcon({ action, claimed, canClaim }: { action: string; claimed: boolean; canClaim: boolean }) {
const iconClass = 'size-4';
const icon = (() => {
switch (action) {
case 'interact': return <Heart className={iconClass} />;
case 'feed': return <Utensils className={iconClass} />;
case 'clean': return <Droplets className={iconClass} />;
case 'sleep': return <Moon className={iconClass} />;
case 'take_photo': return <Camera className={iconClass} />;
case 'sing': return <Mic className={iconClass} />;
case 'play_music': return <Music className={iconClass} />;
case 'medicine': return <Pill className={iconClass} />;
default: return <Target className={iconClass} />;
}
})();
return (
<div className={cn(
'size-8 rounded-full flex items-center justify-center shrink-0',
claimed ? 'bg-emerald-500/15 text-emerald-500' : canClaim ? 'bg-amber-500/15 text-amber-500' : 'bg-muted/60 text-muted-foreground',
)}>
{claimed ? <Check className="size-4" /> : icon}
</div>
);
}
@@ -0,0 +1,162 @@
// src/blobbi/rooms/components/BlobbiHomeRoom.tsx
/**
* BlobbiHomeRoom — The main living / play room.
*
* Layout:
* - BlobbiRoomHero (stats crown, Blobbi visual, name)
* - Unified bottom bar: Photo (left) | Carousel (center) | Companion (right)
* - Inline activity (music player, sing card) above the bottom bar
*
* Sleep/wake has been moved to BlobbiRestRoom.
*/
import { useMemo } from 'react';
import { Camera, Footprints, Music, Mic } from 'lucide-react';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import { InlineMusicPlayer, InlineSingCard } from '@/blobbi/actions';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { BlobbiRoomHero } from './BlobbiRoomHero';
import { RoomActionButton } from './RoomActionButton';
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
interface BlobbiHomeRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) {
const {
isActiveFloatingCompanion,
setShowPhotoModal,
isCurrentCompanion,
canBeCompanion,
isUpdatingCompanion,
handleSetAsCompanion,
isUsingItem,
usingItemId,
handleUseItemFromTab,
handleDirectAction,
isDirectActionPending,
inlineActivity,
handleConfirmSing,
handleCloseInlineActivity,
handleMusicPlaybackStart,
handleMusicPlaybackStop,
handleSingRecordingStart,
handleSingRecordingStop,
handleChangeTrack,
isPublishing,
actionInProgress,
} = ctx;
// Build carousel entries: toys + music + sing
const carouselItems = useMemo<CarouselEntry[]>(() => {
const toys = getLiveShopItems()
.filter(i => i.type === 'toy')
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name }));
const actions: CarouselEntry[] = [
{
id: '__action_music',
icon: <div className="size-10 sm:size-12 rounded-full flex items-center justify-center bg-pink-500/15 text-pink-500"><Music className="size-5 sm:size-6" /></div>,
label: 'Music',
},
{
id: '__action_sing',
icon: <div className="size-10 sm:size-12 rounded-full flex items-center justify-center bg-purple-500/15 text-purple-500"><Mic className="size-5 sm:size-6" /></div>,
label: 'Sing',
},
];
return [...toys, ...actions];
}, []);
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
const handleCarouselUse = (id: string) => {
if (id === '__action_music') {
handleDirectAction('play_music');
} else if (id === '__action_sing') {
handleDirectAction('sing');
} else {
handleUseItemFromTab(id);
}
};
return (
<div className="flex flex-col flex-1 min-h-0">
{/* ── Hero (Blobbi + stats) ── */}
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* ── Inline Activity Area (music/sing) ── */}
{inlineActivity.type === 'music' && (
<div className="px-4 sm:px-6 pb-2">
<InlineMusicPlayer
selection={inlineActivity.selection}
onChangeTrack={handleChangeTrack}
onClose={handleCloseInlineActivity}
onPlaybackStart={handleMusicPlaybackStart}
onPlaybackStop={handleMusicPlaybackStop}
isPublished={inlineActivity.isPublished}
isPublishing={isDirectActionPending}
/>
</div>
)}
{inlineActivity.type === 'sing' && (
<div className="px-4 sm:px-6 pb-2">
<InlineSingCard
onConfirm={handleConfirmSing}
onClose={handleCloseInlineActivity}
onRecordingStart={handleSingRecordingStart}
onRecordingStop={handleSingRecordingStop}
isPublishing={isDirectActionPending}
/>
</div>
)}
{/* ── Unified Bottom Bar: Photo | Carousel | Companion ── */}
{!isActiveFloatingCompanion && (
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Photo */}
<RoomActionButton
icon={<Camera className="size-7 sm:size-9" />}
label="Photo"
color="text-pink-500"
glowHex="#ec4899"
onClick={() => setShowPhotoModal(true)}
/>
{/* Center carousel */}
<div className="flex-1 min-w-0 flex justify-center">
<ItemCarousel
items={carouselItems}
onUse={handleCarouselUse}
activeItemId={isUsingItem ? usingItemId : null}
disabled={isDisabled}
/>
</div>
{/* Companion toggle */}
{canBeCompanion ? (
<RoomActionButton
icon={<Footprints className="size-7 sm:size-9" />}
label={isCurrentCompanion ? 'With you' : 'Take along'}
color={isCurrentCompanion ? 'text-emerald-500' : 'text-violet-500'}
glowHex={isCurrentCompanion ? '#10b981' : '#8b5cf6'}
onClick={handleSetAsCompanion}
disabled={isUpdatingCompanion}
loading={isUpdatingCompanion}
/>
) : (
<div className="w-14 sm:w-20 shrink-0" />
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,148 @@
// src/blobbi/rooms/components/BlobbiKitchenRoom.tsx
/**
* BlobbiKitchenRoom — The feeding room.
*
* Bottom bar: Shovel (left, when poop exists) | food carousel (center) | Fridge (right)
* Poop appears at pre-computed safe positions in the lower corners.
*/
import { useMemo, useState } from 'react';
import { Refrigerator, Shovel } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import { BlobbiActionInventoryModal } from '@/blobbi/actions/components/BlobbiActionInventoryModal';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { getPoopsInRoom, hasAnyPoop } from '../lib/poop-system';
import { BlobbiRoomHero } from './BlobbiRoomHero';
import { RoomActionButton } from './RoomActionButton';
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
interface BlobbiKitchenRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
export function BlobbiKitchenRoom({ ctx, poopState }: BlobbiKitchenRoomProps) {
const {
companion,
profile,
isUsingItem,
usingItemId,
handleUseItemFromTab,
isPublishing,
actionInProgress,
isActiveFloatingCompanion,
} = ctx;
const [showFridge, setShowFridge] = useState(false);
const foodEntries = useMemo<CarouselEntry[]>(() =>
getLiveShopItems()
.filter(i => i.type === 'food')
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name })),
[]);
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
const handleFridgeUseItem = (itemId: string) => {
if (isUsingItem) return;
ctx.onUseItem(itemId, 'feed').finally(() => {
setShowFridge(false);
});
};
const kitchenPoops = getPoopsInRoom(poopState.poops, 'kitchen');
const anyPoopAnywhere = hasAnyPoop(poopState.poops);
return (
<div className="flex flex-col flex-1 min-h-0">
{/* ── Hero + Poop layer ── */}
<div className="relative flex-1 min-h-0 flex flex-col">
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* Poop at pre-computed safe positions */}
{kitchenPoops.map((poop) => (
<button
key={poop.id}
onClick={() => poopState.shovelMode && poopState.onRemovePoop(poop.id)}
className={cn(
'absolute z-10 transition-all duration-300',
poopState.shovelMode
? 'cursor-pointer hover:scale-150 active:scale-75'
: 'pointer-events-none',
)}
style={{
bottom: `${poop.position.bottom}%`,
left: `${poop.position.left}%`,
}}
>
<span className={cn(
'text-2xl sm:text-3xl block',
poopState.shovelMode && 'drop-shadow-lg',
)}>
{'💩'}
</span>
</button>
))}
</div>
{/* ── Bottom bar ── */}
{!isActiveFloatingCompanion && (
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Left — Shovel (when poop exists) or spacer */}
{anyPoopAnywhere ? (
<RoomActionButton
icon={<Shovel className="size-7 sm:size-9" />}
label={poopState.shovelMode ? 'Done' : 'Shovel'}
color={poopState.shovelMode ? 'text-amber-600' : 'text-stone-500'}
glowHex={poopState.shovelMode ? '#d97706' : '#78716c'}
onClick={() => poopState.setShovelMode(prev => !prev)}
className={poopState.shovelMode ? 'ring-2 ring-amber-500/40 ring-offset-2 ring-offset-background rounded-full' : ''}
/>
) : (
<div className="w-14 sm:w-20 shrink-0" />
)}
{/* Center: food carousel */}
<div className="flex-1 min-w-0 flex justify-center">
<ItemCarousel
items={foodEntries}
onUse={handleUseItemFromTab}
activeItemId={isUsingItem ? usingItemId : null}
disabled={isDisabled}
/>
</div>
{/* Right — Fridge */}
<RoomActionButton
icon={<Refrigerator className="size-7 sm:size-9" />}
label="Fridge"
color="text-orange-500"
glowHex="#f97316"
onClick={() => setShowFridge(true)}
disabled={isDisabled}
/>
</div>
</div>
)}
{showFridge && (
<BlobbiActionInventoryModal
open={showFridge}
onOpenChange={setShowFridge}
action="feed"
companion={companion}
profile={profile}
onUseItem={handleFridgeUseItem}
onOpenShop={() => setShowFridge(false)}
isUsingItem={isUsingItem}
usingItemId={usingItemId}
/>
)}
</div>
);
}
@@ -0,0 +1,62 @@
// src/blobbi/rooms/components/BlobbiRestRoom.tsx
/**
* BlobbiRestRoom — The bedroom / rest room.
*
* Bottom bar: Sleep/Wake button centered.
*/
import { Moon, Sun, Loader2 } from 'lucide-react';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { BlobbiRoomHero } from './BlobbiRoomHero';
import { RoomActionButton } from './RoomActionButton';
interface BlobbiRestRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
export function BlobbiRestRoom({ ctx }: BlobbiRestRoomProps) {
const {
isEgg,
isSleeping,
onRest,
actionInProgress,
isPublishing,
isUsingItem,
isActiveFloatingCompanion,
} = ctx;
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
return (
<div className="flex flex-col flex-1 min-h-0">
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{!isActiveFloatingCompanion && (
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-center">
{!isEgg && (
<RoomActionButton
icon={
actionInProgress === 'rest'
? <Loader2 className="size-7 sm:size-9 animate-spin" />
: isSleeping
? <Sun className="size-7 sm:size-9" />
: <Moon className="size-7 sm:size-9" />
}
label={isSleeping ? 'Wake up' : 'Sleep'}
color={isSleeping ? 'text-amber-500' : 'text-violet-500'}
glowHex={isSleeping ? '#f59e0b' : '#8b5cf6'}
onClick={onRest}
disabled={isDisabled}
/>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,284 @@
// src/blobbi/rooms/components/BlobbiRoomHero.tsx
/**
* BlobbiRoomHero — Shared Blobbi visual display used in every room.
*
* This component does NOT clip or constrain the visual — it simply fills
* available flex space and centers the Blobbi + stats within it.
* The room owns the full-height surface; this just provides content.
*
* Top padding accounts for the floating room header overlay (~40px).
*/
import { useMemo } from 'react';
import {
Utensils, Gamepad2, Heart, Droplets, Zap, AlertTriangle,
Footprints, Loader2,
} from 'lucide-react';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
import { cn } from '@/lib/utils';
import type { BlobbiRoomContext } from '../lib/room-types';
// ─── Stat colour maps ─────────────────────────────────────────────────────────
const STAT_COLOR_MAP: Record<string, 'orange' | 'yellow' | 'green' | 'blue' | 'violet'> = {
hunger: 'orange',
happiness: 'yellow',
health: 'green',
hygiene: 'blue',
energy: 'violet',
};
const STAT_COLORS: Record<string, string> = {
orange: 'text-orange-500',
yellow: 'text-yellow-500',
green: 'text-green-500',
blue: 'text-blue-500',
violet: 'text-violet-500',
};
const STAT_BG_COLORS: Record<string, string> = {
orange: 'bg-orange-500/10',
yellow: 'bg-yellow-500/10',
green: 'bg-green-500/10',
blue: 'bg-blue-500/10',
violet: 'bg-violet-500/10',
};
const STAT_RING_HEX: Record<string, string> = {
orange: '#f97316',
yellow: '#eab308',
green: '#22c55e',
blue: '#3b82f6',
violet: '#8b5cf6',
};
const STAT_ICON_MAP: Record<string, React.ComponentType<{ className?: string; strokeWidth?: number }>> = {
hunger: Utensils,
happiness: Gamepad2,
health: Heart,
hygiene: Droplets,
energy: Zap,
};
// ─── Props ────────────────────────────────────────────────────────────────────
interface BlobbiRoomHeroProps {
ctx: BlobbiRoomContext;
className?: string;
hideStats?: boolean;
hideName?: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRoomHeroProps) {
const {
companion,
currentStats,
isSleeping,
isEgg,
statusRecipe,
statusRecipeLabel,
effectiveEmotion,
hasDevOverride,
blobbiReaction,
isActiveFloatingCompanion,
isUpdatingCompanion,
handleSetAsCompanion,
heroRef,
heroWidth,
} = ctx;
if (isActiveFloatingCompanion) {
return (
<div className={cn('flex flex-col items-center justify-center gap-4 text-center flex-1 px-4', className)}>
<Footprints className="size-12 text-muted-foreground/30" />
<p className="text-muted-foreground text-sm">
{companion.name} is out exploring right now.
</p>
<button
onClick={handleSetAsCompanion}
disabled={isUpdatingCompanion}
className={cn(
'flex items-center justify-center gap-2 px-6 py-3 rounded-full text-white font-semibold transition-all duration-300 ease-out text-sm',
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
isUpdatingCompanion && 'opacity-50 pointer-events-none',
)}
style={{ background: 'linear-gradient(135deg, #8b5cf6, #ec4899, #f59e0b)' }}
>
{isUpdatingCompanion ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Footprints className="size-4" />
)}
<span>Bring {companion.name} home</span>
</button>
</div>
);
}
return (
<div
ref={heroRef}
className={cn(
// No overflow-hidden — let the room own the visual surface.
// pt-10 creates clearance for the floating room header overlay.
'relative flex flex-col items-center justify-center pt-10 px-4 sm:px-6 flex-1 min-h-0',
className,
)}
>
<div className="relative flex flex-col items-center">
{/* Stats crown */}
{!hideStats && <StatsCrown companion={companion} currentStats={currentStats} heroWidth={heroWidth} />}
{/* Blobbi visual */}
<div
className="relative transition-all duration-500"
style={!isSleeping ? {
animation: `blobbi-bob ${4 - (currentStats.happiness / 100) * 1.5}s ease-in-out infinite, blobbi-sway ${6 - (currentStats.happiness / 100) * 2}s ease-in-out infinite`,
} : undefined}
>
<div className="absolute inset-0 -m-16 sm:-m-20 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
<BlobbiStageVisual
companion={companion}
size="lg"
animated={!isSleeping}
reaction={blobbiReaction}
recipe={hasDevOverride ? undefined : statusRecipe}
recipeLabel={hasDevOverride ? undefined : statusRecipeLabel}
emotion={effectiveEmotion}
className={isEgg
? 'size-36 min-[400px]:size-44 sm:size-56 md:size-64 lg:size-72'
: 'size-48 min-[400px]:size-60 sm:size-72 md:size-80 lg:size-96'
}
/>
</div>
{/* Blobbi Name */}
{!hideName && !isEgg && (
<h2
className="text-xl sm:text-2xl md:text-3xl font-bold text-center mt-1"
style={{ color: companion.visualTraits.baseColor }}
>
{companion.name}
</h2>
)}
</div>
</div>
);
}
// ─── Stats Crown ──────────────────────────────────────────────────────────────
function StatsCrown({
companion,
currentStats,
heroWidth,
}: {
companion: BlobbiRoomContext['companion'];
currentStats: BlobbiRoomContext['currentStats'];
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],
})),
[companion.stage, currentStats]);
if (allStats.length === 0) return null;
const count = allStats.length;
const isSmall = heroWidth < 400;
// Balanced arc: mobile is compact, desktop has moderate breathing room.
// These values produce a stable crown with no dramatic changes between
// 375px (mobile) and 640px+ (desktop) — a smooth interpolation.
const arcSpread = isSmall
? (count <= 2 ? 80 : count <= 3 ? 110 : 140)
: (count <= 2 ? 90 : count <= 3 ? 130 : 160);
const arcHalf = arcSpread / 2;
const angles = count === 1
? [0]
: allStats.map((_, i) => -arcHalf + (arcSpread / (count - 1)) * i);
return (
<div className="relative flex items-end justify-center w-full mb-4 sm:mb-8" style={{ height: 40 }}>
{allStats.map((s, i) => {
const angleDeg = angles[i];
const angleRad = (angleDeg * Math.PI) / 180;
// Smooth interpolation: 110px at 340px width → 200px at 640px+ width
const radius = Math.min(200, Math.max(110, (heroWidth - 340) / (640 - 340) * (200 - 110) + 110));
const x = Math.sin(angleRad) * radius;
const y = Math.cos(angleRad) * radius - radius;
return (
<div
key={s.stat}
className="absolute transition-all duration-500"
style={{
transform: `translate(-50%, 0)`,
left: `calc(50% + ${x.toFixed(1)}px)`,
bottom: `${y.toFixed(1)}px`,
}}
>
<StatIndicator
stat={s.stat}
value={s.value}
color={s.color}
status={s.status}
/>
</div>
);
})}
</div>
);
}
// ─── Stat Indicator ───────────────────────────────────────────────────────────
interface StatIndicatorProps {
stat: string;
value: number | undefined;
color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet';
status?: 'normal' | 'warning' | 'critical';
}
function StatIndicator({ stat, value, color, status = 'normal' }: StatIndicatorProps) {
const displayValue = value ?? 0;
const isLow = status === 'warning' || status === 'critical';
const ringHex = STAT_RING_HEX[color];
const IconComponent = STAT_ICON_MAP[stat];
return (
<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',
)}>
<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" />
<circle
cx="18" cy="18" r="15" fill="none" strokeWidth="2.5" strokeLinecap="round"
stroke={ringHex}
strokeDasharray={`${displayValue * 0.94} 100`}
className="transition-all duration-500"
/>
</svg>
<div className="relative">
{IconComponent && <IconComponent className={cn('size-5 sm:size-6', STAT_COLORS[color])} strokeWidth={2.5} />}
{isLow && (
<AlertTriangle
className={cn('absolute -top-1.5 -right-2 size-3', status === 'critical' ? 'text-red-500' : 'text-amber-500')}
strokeWidth={3}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,201 @@
// src/blobbi/rooms/components/BlobbiRoomShell.tsx
/**
* BlobbiRoomShell — The outer layout for the room-based Blobbi dashboard.
*
* Manages:
* - Current room state + navigation
* - Sleep dark overlay (scoped to this shell only)
* - Ephemeral poop instances (local-only, no persistence)
*/
import { useState, useCallback, useMemo, useEffect } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from '@/hooks/useToast';
import {
type BlobbiRoomId,
ROOM_META,
DEFAULT_ROOM_ORDER,
DEFAULT_INITIAL_ROOM,
getNextRoom,
getPreviousRoom,
getRoomIndex,
} from '../lib/room-config';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import {
generateInitialPoops,
removePoop,
type PoopInstance,
} from '../lib/poop-system';
import { BlobbiHomeRoom } from './BlobbiHomeRoom';
import { BlobbiKitchenRoom } from './BlobbiKitchenRoom';
import { BlobbiCareRoom } from './BlobbiCareRoom';
import { BlobbiHatcheryRoom } from './BlobbiHatcheryRoom';
import { BlobbiRestRoom } from './BlobbiRestRoom';
import { BlobbiClosetRoom } from './BlobbiClosetRoom';
// ─── Types ────────────────────────────────────────────────────────────────────
interface BlobbiRoomShellProps {
ctx: BlobbiRoomContext;
roomOrder?: BlobbiRoomId[];
initialRoom?: BlobbiRoomId;
}
interface RoomNavState {
current: BlobbiRoomId;
direction: 'left' | 'right' | null;
}
// ─── Room Component Map ───────────────────────────────────────────────────────
const ROOM_COMPONENTS: Record<BlobbiRoomId, React.ComponentType<{ ctx: BlobbiRoomContext; poopState: RoomPoopState }>> = {
care: BlobbiCareRoom,
kitchen: BlobbiKitchenRoom,
home: BlobbiHomeRoom,
hatchery: BlobbiHatcheryRoom,
rest: BlobbiRestRoom,
closet: BlobbiClosetRoom,
};
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiRoomShell({
ctx,
roomOrder = DEFAULT_ROOM_ORDER,
initialRoom = DEFAULT_INITIAL_ROOM,
}: BlobbiRoomShellProps) {
const [nav, setNav] = useState<RoomNavState>({
current: roomOrder.includes(initialRoom) ? initialRoom : roomOrder[0],
direction: null,
});
const goRight = useCallback(() => {
setNav(prev => ({
current: getNextRoom(prev.current, roomOrder),
direction: 'right',
}));
}, [roomOrder]);
const goLeft = useCallback(() => {
setNav(prev => ({
current: getPreviousRoom(prev.current, roomOrder),
direction: 'left',
}));
}, [roomOrder]);
const meta = ROOM_META[nav.current];
const roomIndex = getRoomIndex(nav.current, roomOrder);
const RoomComponent = ROOM_COMPONENTS[nav.current];
const dots = useMemo(() => roomOrder.map((id, i) => ({
id,
active: i === roomIndex,
label: ROOM_META[id].label,
})), [roomOrder, roomIndex]);
const isSleeping = ctx.isSleeping;
// ─── Poop system (ephemeral, local-only) ───
const [poops, setPoops] = useState<PoopInstance[]>([]);
const [shovelMode, setShovelMode] = useState(false);
// Generate poop on mount
useEffect(() => {
const hunger = ctx.currentStats.hunger;
const lastFeed = ctx.lastFeedTimestamp ?? ctx.companion.lastInteraction * 1000;
setPoops(generateInitialPoops(hunger, lastFeed));
// Only run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onRemovePoop = useCallback((poopId: string) => {
setPoops(prev => {
const { remaining, xpReward } = removePoop(prev, poopId);
if (xpReward > 0) {
toast({ title: `+${xpReward} XP`, description: 'Cleaned up!' });
}
if (remaining.length === 0) {
setShovelMode(false);
}
return remaining;
});
}, []);
const poopState: RoomPoopState = useMemo(() => ({
poops,
shovelMode,
setShovelMode,
onRemovePoop,
}), [poops, shovelMode, onRemovePoop]);
return (
<div className="flex flex-col flex-1 min-h-0 relative">
{/* ── Room Content — fills the entire shell ── */}
<div className="flex-1 min-h-0 flex flex-col relative">
<RoomComponent ctx={ctx} poopState={poopState} />
</div>
{/* ── Sleep overlay — darkens the room when Blobbi sleeps ── */}
{isSleeping && (
<div
className="absolute inset-0 z-20 pointer-events-none transition-opacity duration-700"
style={{ background: 'radial-gradient(ellipse at 50% 40%, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.45) 100%)' }}
/>
)}
{/* ── Floating Room Header ── */}
<div className="absolute inset-x-0 top-0 z-30 pointer-events-none">
<div className="flex flex-col items-center pt-2 pb-1">
<div className="flex items-center gap-1.5 pointer-events-auto">
<span className="text-sm">{meta.icon}</span>
<span className="text-xs sm:text-sm font-semibold text-foreground/70">{meta.label}</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
{dots.map(dot => (
<div
key={dot.id}
className={cn(
'rounded-full transition-all duration-300',
dot.active
? 'w-4 h-1 bg-primary'
: 'w-1 h-1 bg-muted-foreground/20',
)}
title={dot.label}
/>
))}
</div>
</div>
</div>
{/* ── Left / Right Navigation Arrows ── */}
<button
onClick={goLeft}
className={cn(
'absolute left-0.5 top-1/2 -translate-y-1/2 z-40',
'size-9 rounded-full flex items-center justify-center',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
)}
aria-label={`Go to ${ROOM_META[getPreviousRoom(nav.current, roomOrder)].label}`}
>
<ChevronLeft className="size-5" />
</button>
<button
onClick={goRight}
className={cn(
'absolute right-0.5 top-1/2 -translate-y-1/2 z-40',
'size-9 rounded-full flex items-center justify-center',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
)}
aria-label={`Go to ${ROOM_META[getNextRoom(nav.current, roomOrder)].label}`}
>
<ChevronRight className="size-5" />
</button>
</div>
);
}
@@ -0,0 +1,166 @@
// src/blobbi/rooms/components/ItemCarousel.tsx
/**
* ItemCarousel — Single-focus carousel for room items.
*
* Layout stability guarantees:
* - The entire carousel width is deterministic (arrows + previews + focus slot)
* - Focused item uses a fixed-size container with overflow-hidden
* - Label is clamped to a fixed max-width and single line
* - Switching items never causes reflow or arrow movement
*
* Mobile: focused item only + compact arrows (no prev/next previews)
* Desktop: focused item + translucent prev/next previews + arrows
*/
import { useState, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface CarouselEntry {
id: string;
/** Emoji string or ReactNode rendered at large size */
icon: React.ReactNode;
label: string;
/** Optional metadata attached to the entry (e.g. item type) */
meta?: string;
}
interface ItemCarouselProps {
items: CarouselEntry[];
/** Called when the user taps the focused item */
onUse: (id: string) => void;
/** Item id currently being used (shows spinner) */
activeItemId?: string | null;
/** Whether any action is in progress */
disabled?: boolean;
/** Called when the focused item changes (for conditional side actions) */
onFocusChange?: (entry: CarouselEntry) => void;
className?: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function ItemCarousel({
items,
onUse,
activeItemId,
disabled,
onFocusChange,
className,
}: ItemCarouselProps) {
const [index, setIndex] = useState(0);
const count = items.length;
const prev = useCallback(() => {
setIndex(i => {
const n = (i - 1 + count) % count;
onFocusChange?.(items[n]);
return n;
});
}, [count, items, onFocusChange]);
const next = useCallback(() => {
setIndex(i => {
const n = (i + 1) % count;
onFocusChange?.(items[n]);
return n;
});
}, [count, items, onFocusChange]);
if (count === 0) {
return (
// Empty state matches the height of a populated carousel
<div className={cn('flex items-center justify-center h-[4.5rem] sm:h-[5.5rem]', className)}>
<p className="text-xs text-muted-foreground/50">Nothing here yet</p>
</div>
);
}
const current = items[index];
const prevItem = items[(index - 1 + count) % count];
const nextItem = items[(index + 1) % count];
const isThisActive = activeItemId === current.id;
const showPreviews = count >= 3;
return (
<div className={cn('flex items-center justify-center', className)}>
{/* Left arrow — fixed 28/32px */}
<button
onClick={prev}
disabled={disabled}
className={cn(
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
disabled && 'opacity-30 pointer-events-none',
)}
aria-label="Previous item"
>
<ChevronLeft className="size-4" />
</button>
{/* Preview (prev) — desktop only, fixed 40x48px slot */}
{showPreviews && (
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
<div className="opacity-20 scale-[0.6]">
<span className="text-2xl leading-none block">{prevItem.icon}</span>
</div>
</div>
)}
{/* Focused item — FIXED 80x72 / 96x88 container, never resizes */}
<button
onClick={() => onUse(current.id)}
disabled={disabled}
className={cn(
'relative flex flex-col items-center justify-center shrink-0 overflow-hidden',
'w-20 h-[4.5rem] sm:w-24 sm:h-[5.5rem] rounded-2xl',
'transition-colors duration-200',
'hover:bg-accent/20 active:scale-95',
isThisActive && 'bg-accent/40',
disabled && !isThisActive && 'opacity-50 pointer-events-none',
)}
>
<span className="text-4xl sm:text-5xl leading-none">
{current.icon}
</span>
{/* Label: fixed max-width, single line, ellipsis */}
<span className="text-[10px] sm:text-xs font-medium text-foreground/70 mt-0.5 w-16 sm:w-20 text-center truncate">
{current.label}
</span>
{isThisActive && (
<Loader2 className="size-3.5 animate-spin text-primary absolute bottom-0.5" />
)}
</button>
{/* Preview (next) — desktop only, fixed 40x48px slot */}
{showPreviews && (
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
<div className="opacity-20 scale-[0.6]">
<span className="text-2xl leading-none block">{nextItem.icon}</span>
</div>
</div>
)}
{/* Right arrow — fixed 28/32px */}
<button
onClick={next}
disabled={disabled}
className={cn(
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
disabled && 'opacity-30 pointer-events-none',
)}
aria-label="Next item"
>
<ChevronRight className="size-4" />
</button>
</div>
);
}
@@ -0,0 +1,79 @@
// src/blobbi/rooms/components/RoomActionButton.tsx
/**
* RoomActionButton — Unified circular action button for all rooms.
*
* Responsive sizing:
* - Mobile: size-14 circle, size-7 icons
* - Desktop (sm+): size-20 circle, size-9 icons
*
* Matches the soft radial glow of the original Photo / Companion buttons
* but at a smaller scale so the bottom bar feels proportional on mobile.
*/
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface RoomActionButtonProps {
/** Lucide icon or emoji element rendered inside the circle */
icon: React.ReactNode;
/** Small text label below the circle */
label: string;
/** CSS colour class applied to the icon (e.g. 'text-pink-500') */
color: string;
/** Hex colour used for the radial glow background */
glowHex: string;
onClick: () => void;
disabled?: boolean;
loading?: boolean;
/** Optional badge content rendered at top-right of the circle */
badge?: React.ReactNode;
className?: string;
}
export function RoomActionButton({
icon,
label,
color,
glowHex,
onClick,
disabled,
loading,
badge,
className,
}: RoomActionButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
'flex flex-col items-center gap-1 transition-all duration-300 ease-out shrink-0',
'hover:-translate-y-1 hover:scale-110 active:scale-95',
disabled && 'opacity-50 pointer-events-none',
className,
)}
>
<div className="relative">
<div
className={cn('size-14 sm:size-20 rounded-full flex items-center justify-center', color)}
style={{
background: `radial-gradient(circle at 40% 35%, color-mix(in srgb, ${glowHex} 14%, transparent), color-mix(in srgb, ${glowHex} 4%, transparent) 70%)`,
}}
>
{loading ? (
<Loader2 className="size-7 sm:size-9 animate-spin" />
) : (
icon
)}
</div>
{badge && (
<div className="absolute -top-0.5 -right-0.5">
{badge}
</div>
)}
</div>
<span className="text-[10px] sm:text-xs font-medium text-muted-foreground">{label}</span>
</button>
);
}
+20
View File
@@ -0,0 +1,20 @@
// src/blobbi/rooms/index.ts — barrel export
export {
type BlobbiRoomId,
type BlobbiRoomMeta,
ROOM_META,
DEFAULT_ROOM_ORDER,
DEFAULT_INITIAL_ROOM,
getNextRoom,
getPreviousRoom,
getRoomIndex,
} from './lib/room-config';
export { BlobbiRoomShell } from './components/BlobbiRoomShell';
export { BlobbiHomeRoom } from './components/BlobbiHomeRoom';
export { BlobbiKitchenRoom } from './components/BlobbiKitchenRoom';
export { BlobbiCareRoom } from './components/BlobbiCareRoom';
export { BlobbiHatcheryRoom } from './components/BlobbiHatcheryRoom';
export { BlobbiRestRoom } from './components/BlobbiRestRoom';
export { BlobbiClosetRoom } from './components/BlobbiClosetRoom';
+137
View File
@@ -0,0 +1,137 @@
// src/blobbi/rooms/lib/poop-system.ts
/**
* Temporary local-only poop system.
*
* Generates poop based on:
* A) Overfeeding: hunger >= 95 -> poop in kitchen
* B) Time elapsed: every 2 hours since last feed -> poop in a random room
*
* This is entirely ephemeral -- no persistence to Nostr or localStorage.
* The state is generated fresh on page load and managed in React state.
*/
import type { BlobbiRoomId } from './room-config';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface PoopInstance {
id: string;
room: BlobbiRoomId;
/** 'overfeed' poops are kitchen-only and disappear on room change */
source: 'overfeed' | 'time';
/** Timestamp when this poop was generated */
createdAt: number;
/**
* Safe-zone position for this poop.
* Kept as % offsets so the layout stays responsive.
* Positions are in the lower-left and lower-right corners,
* avoiding the central Blobbi hero area.
*/
position: { bottom: number; left: number };
}
// ─── Constants ────────────────────────────────────────────────────────────────
const OVERFEED_THRESHOLD = 95;
const HOURS_PER_POOP = 2;
const XP_PER_POOP = 5;
/** Rooms where time-based poop can appear (not closet) */
const POOP_ELIGIBLE_ROOMS: BlobbiRoomId[] = ['care', 'kitchen', 'home', 'hatchery', 'rest'];
/**
* Pre-defined safe positions in the lower corners of the room.
* Values are percentages. These avoid the central hero area
* (roughly 30%70% horizontal, above 35% vertical).
*/
const SAFE_POSITIONS: Array<{ bottom: number; left: number }> = [
{ bottom: 22, left: 8 }, // lower-left
{ bottom: 18, left: 78 }, // lower-right
{ bottom: 28, left: 14 }, // mid-left
{ bottom: 25, left: 82 }, // mid-right
{ bottom: 15, left: 20 }, // bottom-left-ish
{ bottom: 20, left: 72 }, // bottom-right-ish
];
// ─── Generation ───────────────────────────────────────────────────────────────
let _idCounter = 0;
function nextPoopId(): string {
return `poop_${++_idCounter}_${Date.now()}`;
}
function pickPosition(index: number): { bottom: number; left: number } {
return SAFE_POSITIONS[index % SAFE_POSITIONS.length];
}
/**
* Generate initial poop instances based on current companion state.
* Called once when the dashboard mounts.
*/
export function generateInitialPoops(
hunger: number,
lastFeedTimestamp: number | undefined,
): PoopInstance[] {
const poops: PoopInstance[] = [];
const now = Date.now();
let posIndex = 0;
// A) Overfeeding poop -- kitchen only
if (hunger >= OVERFEED_THRESHOLD) {
poops.push({
id: nextPoopId(),
room: 'kitchen',
source: 'overfeed',
createdAt: now,
position: pickPosition(posIndex++),
});
}
// B) Time-based poop -- random room
if (lastFeedTimestamp) {
const hoursSinceFeed = (now - lastFeedTimestamp) / (1000 * 60 * 60);
const poopCount = Math.floor(hoursSinceFeed / HOURS_PER_POOP);
for (let i = 0; i < Math.min(poopCount, 3); i++) {
const room = POOP_ELIGIBLE_ROOMS[Math.floor(Math.random() * POOP_ELIGIBLE_ROOMS.length)];
poops.push({
id: nextPoopId(),
room,
source: 'time',
createdAt: now - i * 1000,
position: pickPosition(posIndex++),
});
}
}
return poops;
}
/**
* Get poops visible in a specific room.
*/
export function getPoopsInRoom(poops: PoopInstance[], room: BlobbiRoomId): PoopInstance[] {
return poops.filter(p => p.room === room);
}
/**
* Remove a poop by id and return the XP reward.
*/
export function removePoop(
poops: PoopInstance[],
poopId: string,
): { remaining: PoopInstance[]; xpReward: number } {
const remaining = poops.filter(p => p.id !== poopId);
const wasRemoved = remaining.length < poops.length;
return {
remaining,
xpReward: wasRemoved ? XP_PER_POOP : 0,
};
}
/**
* Check if any poop exists anywhere.
*/
export function hasAnyPoop(poops: PoopInstance[]): boolean {
return poops.length > 0;
}
+144
View File
@@ -0,0 +1,144 @@
// src/blobbi/rooms/lib/room-config.ts
/**
* Blobbi Room System — Configuration & Navigation
*
* This module defines the room types, default ordering, and navigation helpers.
* The design supports future per-user customisation: the default order is data,
* not hardcoded control flow, so it can be replaced with a user-stored sequence.
*/
// ─── Room IDs ─────────────────────────────────────────────────────────────────
/**
* Unique identifier for each room in the Blobbi world.
* New rooms can be added here without breaking existing code.
*/
export type BlobbiRoomId = 'care' | 'kitchen' | 'home' | 'hatchery' | 'rest' | 'closet';
// ─── Room Metadata ────────────────────────────────────────────────────────────
export interface BlobbiRoomMeta {
/** Unique room identifier */
id: BlobbiRoomId;
/** Human-readable display label */
label: string;
/** Short description (for tooltips / accessibility) */
description: string;
/** Emoji icon representing the room */
icon: string;
}
/**
* Static metadata for every room.
* This is a lookup — order does NOT matter here.
*/
export const ROOM_META: Record<BlobbiRoomId, BlobbiRoomMeta> = {
care: {
id: 'care',
label: 'Care Room',
description: 'Hygiene, care, and medicine',
icon: '🩹',
},
kitchen: {
id: 'kitchen',
label: 'Kitchen',
description: 'Feed your Blobbi',
icon: '🍳',
},
home: {
id: 'home',
label: 'Home',
description: 'Main living room',
icon: '🏠',
},
hatchery: {
id: 'hatchery',
label: 'Hatchery',
description: 'Evolution and quests',
icon: '🥚',
},
rest: {
id: 'rest',
label: 'Bedroom',
description: 'Rest and recharge',
icon: '🌙',
},
closet: {
id: 'closet',
label: 'Closet',
description: 'Wardrobe and accessories',
icon: '👗',
},
};
// ─── Default Room Order ───────────────────────────────────────────────────────
/**
* The default room sequence.
*
* IMPORTANT: This array is the ONLY place that defines order.
* To support per-user customisation later, replace this with
* a user-stored array of BlobbiRoomId values.
*
* Closet is excluded for now (not yet implemented).
* To re-enable, add 'closet' back to the array.
*/
export const DEFAULT_ROOM_ORDER: BlobbiRoomId[] = [
'care',
'kitchen',
'home',
'hatchery',
'rest',
// 'closet', — re-enable when wardrobe feature is ready
];
/**
* The room that should be selected when the dashboard first loads.
*/
export const DEFAULT_INITIAL_ROOM: BlobbiRoomId = 'home';
// ─── Navigation Helpers ───────────────────────────────────────────────────────
/**
* Get the next room in a looping sequence.
*
* @param current - The currently active room
* @param order - The room sequence (defaults to DEFAULT_ROOM_ORDER)
* @returns The next room id (wraps around)
*/
export function getNextRoom(
current: BlobbiRoomId,
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
): BlobbiRoomId {
const idx = order.indexOf(current);
if (idx === -1) return order[0];
return order[(idx + 1) % order.length];
}
/**
* Get the previous room in a looping sequence.
*
* @param current - The currently active room
* @param order - The room sequence (defaults to DEFAULT_ROOM_ORDER)
* @returns The previous room id (wraps around)
*/
export function getPreviousRoom(
current: BlobbiRoomId,
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
): BlobbiRoomId {
const idx = order.indexOf(current);
if (idx === -1) return order[order.length - 1];
return order[(idx - 1 + order.length) % order.length];
}
/**
* Get the index of a room in the order array.
* Returns -1 if the room is not in the order.
*/
export function getRoomIndex(
room: BlobbiRoomId,
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
): number {
return order.indexOf(room);
}
+15
View File
@@ -0,0 +1,15 @@
// src/blobbi/rooms/lib/room-layout.ts
/**
* Shared layout constants for Blobbi room components.
*/
/**
* CSS class for the bottom action bar in every room.
*
* On mobile/tablet (max-sidebar), adds extra bottom padding so the
* room controls clear the app's fixed bottom navigation bar.
* On desktop (sidebar:), uses normal padding since there's no bottom nav.
*/
export const ROOM_BOTTOM_BAR_CLASS =
'relative z-10 px-3 sm:px-6 pt-1 pb-4 sm:pb-6 max-sidebar:pb-[calc(var(--bottom-nav-height)+env(safe-area-inset-bottom,0px)+1rem)]';
+194
View File
@@ -0,0 +1,194 @@
// src/blobbi/rooms/lib/room-types.ts
/**
* Shared prop types for Blobbi room components.
*
* These types are the "contract" that the BlobbiDashboard passes down
* to each room. They mirror the existing BlobbiDashboard internal state
* so rooms can reuse all existing logic without duplication.
*/
import type { NostrEvent } from '@nostrify/nostrify';
import type { BlobbiCompanion, BlobbonautProfile, StorageItem } from '@/blobbi/core/lib/blobbi';
import type {
InventoryAction,
DirectAction,
InlineActivityState,
BlobbiReactionState,
SelectedTrack,
StartIncubationMode,
} from '@/blobbi/actions';
import type { useHatchTasks, useEvolveTasks, useDailyMissions } from '@/blobbi/actions';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { ShopItem } from '@/blobbi/shop/types/shop.types';
// ─── Shared Dashboard Context ─────────────────────────────────────────────────
/**
* Everything a room needs from the dashboard.
* Passed down by BlobbiRoomShell so rooms don't import dashboard state directly.
*/
export interface BlobbiRoomContext {
// ── Core data ──
companion: BlobbiCompanion;
companions: BlobbiCompanion[];
selectedD: string;
profile: BlobbonautProfile | null;
// ── Projected / visual state ──
currentStats: {
hunger: number;
happiness: number;
health: number;
hygiene: number;
energy: number;
};
isSleeping: boolean;
isEgg: boolean;
isBaby: boolean;
// ── Visual recipe ──
statusRecipe: BlobbiVisualRecipe | undefined;
statusRecipeLabel: string | undefined;
effectiveEmotion: BlobbiEmotion;
hasDevOverride: boolean;
blobbiReaction: BlobbiReactionState;
// ── Item use ──
onUseItem: (itemId: string, action: InventoryAction) => Promise<void>;
handleUseItemFromTab: (itemId: string) => void;
isUsingItem: boolean;
usingItemId: string | null;
allShopItems: ShopItem[];
// ── Direct actions ──
onDirectAction: (action: DirectAction) => Promise<void>;
handleDirectAction: (action: DirectAction) => void;
isDirectActionPending: boolean;
// ── Inline activity (music/sing) ──
inlineActivity: InlineActivityState;
setInlineActivity: React.Dispatch<React.SetStateAction<InlineActivityState>>;
setBlobbiReaction: React.Dispatch<React.SetStateAction<BlobbiReactionState>>;
setActionOverrideEmotion: React.Dispatch<React.SetStateAction<BlobbiEmotion | null>>;
showTrackPickerModal: boolean;
setShowTrackPickerModal: React.Dispatch<React.SetStateAction<boolean>>;
handleTrackSelected: (selection: SelectedTrack) => Promise<void>;
handleConfirmSing: () => Promise<void>;
handleCloseInlineActivity: () => void;
handleMusicPlaybackStart: () => void;
handleMusicPlaybackStop: () => void;
handleSingRecordingStart: () => void;
handleSingRecordingStop: () => void;
handleChangeTrack: () => void;
// ── Rest / sleep ──
onRest: () => void;
actionInProgress: string | null;
isPublishing: boolean;
// ── Companion toggle ──
isCurrentCompanion: boolean;
canBeCompanion: boolean;
isUpdatingCompanion: boolean;
isActiveFloatingCompanion: boolean;
handleSetAsCompanion: () => Promise<void>;
// ── Photo ──
showPhotoModal: boolean;
setShowPhotoModal: React.Dispatch<React.SetStateAction<boolean>>;
// ── Blobbi selector ──
onSelectBlobbi: (d: string) => void;
// ── Incubation / Evolution / Tasks ──
isIncubating: boolean;
isEvolvingState: boolean;
canStartIncubation: boolean;
canStartEvolution: boolean;
isStartingIncubation: boolean;
isStartingEvolution: boolean;
isStoppingIncubation: boolean;
isStoppingEvolution: boolean;
isHatching: boolean;
isEvolving: boolean;
hatchTasks: ReturnType<typeof useHatchTasks>;
evolveTasks: ReturnType<typeof useEvolveTasks>;
onStartIncubation: (mode: StartIncubationMode, stopOtherD?: string) => Promise<void>;
onStartEvolution: () => Promise<void>;
onStopIncubation: () => Promise<void>;
onStopEvolution: () => Promise<void>;
onHatch: () => Promise<void>;
onEvolve: () => Promise<void>;
showPostModal: boolean;
setShowPostModal: React.Dispatch<React.SetStateAction<boolean>>;
refetchCurrentTasks: () => void;
// ── Daily missions ──
dailyMissions: ReturnType<typeof useDailyMissions>;
onClaimReward: (id: string) => void;
isClaimingReward: boolean;
availableStages: ('egg' | 'baby' | 'adult')[];
// ── Adoption ──
showAdoptionFlow: boolean;
setShowAdoptionFlow: React.Dispatch<React.SetStateAction<boolean>>;
// ── Adoption + Profile update props ──
publishEvent: (params: { kind: number; content: string; tags: string[][] }) => Promise<NostrEvent>;
updateProfileEvent: (event: NostrEvent) => void;
updateCompanionEvent: (event: NostrEvent) => void;
invalidateProfile: () => void;
invalidateCompanion: () => void;
setStoredSelectedD: (d: string) => void;
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: StorageItem[];
} | null>;
// ── Naddr link ──
blobbiNaddr: string;
// ── Hero measurement ──
/** Callback ref for the hero container — re-attaches ResizeObserver on room switch */
heroRef: React.RefCallback<HTMLDivElement> | React.RefObject<HTMLDivElement | null>;
heroWidth: number;
// ── DEV ONLY ──
showDevEditor: boolean;
setShowDevEditor: (show: boolean) => void;
onDevEditorApply: (updates: import('@/blobbi/dev').BlobbiDevUpdates) => Promise<void>;
isDevUpdating: boolean;
showEmotionPanel: boolean;
setShowEmotionPanel: React.Dispatch<React.SetStateAction<boolean>>;
showHatchCeremony: boolean;
setShowHatchCeremony: React.Dispatch<React.SetStateAction<boolean>>;
// ── Inventory modal (still used in kitchen) ──
inventoryAction: InventoryAction | null;
setInventoryAction: React.Dispatch<React.SetStateAction<InventoryAction | null>>;
// ── Last feed timestamp (for poop system) ──
lastFeedTimestamp: number | undefined;
}
// ─── Poop State (passed from shell to rooms) ──────────────────────────────────
import type { PoopInstance } from './poop-system';
export interface RoomPoopState {
/** All poop instances across rooms */
poops: PoopInstance[];
/** Whether shovel mode is currently active */
shovelMode: boolean;
/** Toggle shovel mode on/off */
setShovelMode: React.Dispatch<React.SetStateAction<boolean>>;
/** Remove a poop (returns XP reward via callback) */
onRemovePoop: (poopId: string) => void;
}
@@ -87,7 +87,7 @@ export function useBlobbiPurchaseItem(currentProfile: BlobbonautProfile | null)
// Publish updated profile event
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
content: currentProfile.event.content,
tags: updatedTags,
});
@@ -83,7 +83,7 @@ export function useBlobbonautProfileNormalization({
// Always publish to the NEW kind (11125), regardless of source kind
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
content: profile.event.content,
tags: normalizedTags,
});
+194 -1159
View File
File diff suppressed because it is too large Load Diff