Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96387d9941 | |||
| 9848d84f4f | |||
| ff758b078c | |||
| fe5221e973 | |||
| c48079406d | |||
| 76623cd510 | |||
| f1b0868e30 | |||
| ffdf6f0f36 | |||
| c965ff27c4 | |||
| f5f7c90ce4 | |||
| 4e5dbed3d2 | |||
| 508a16234f | |||
| 4ecb3209bd | |||
| 286572777b | |||
| 7fd4b7ab69 | |||
| c9525a0233 | |||
| 0b9cd5e1cb | |||
| a2600d1caa | |||
| 0722d900a2 | |||
| 918814371c |
Generated
+2
-2
@@ -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,
|
||||
@@ -171,11 +175,16 @@ function DailyMissionsSection({
|
||||
bonusReward,
|
||||
noMissionsAvailable,
|
||||
rerollsRemaining,
|
||||
} = useDailyMissions({ availableStages });
|
||||
} = useDailyMissions({
|
||||
availableStages,
|
||||
persistedDailyMissions: profile?.content.dailyMissions,
|
||||
});
|
||||
|
||||
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
companion,
|
||||
updateCompanionEvent,
|
||||
);
|
||||
|
||||
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
|
||||
@@ -194,7 +203,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 +224,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>
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
/**
|
||||
* 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 the in-memory session store
|
||||
* - Idempotent claiming (prevents double-credit)
|
||||
* - Optimistic cache updates
|
||||
*
|
||||
* Kind 11125 content JSON is the persistent source of truth.
|
||||
* The in-memory session store is updated for immediate UI feedback.
|
||||
*/
|
||||
|
||||
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 {
|
||||
updateDailyMissionsContent,
|
||||
missionToPersistedMission,
|
||||
type PersistedDailyMissions,
|
||||
} from '@/blobbi/core/lib/blobbonaut-content';
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
getTodayDateString,
|
||||
@@ -27,6 +38,8 @@ import {
|
||||
isBonusMissionAvailable,
|
||||
isBonusMissionClaimed,
|
||||
BONUS_MISSION_DEFINITION,
|
||||
readDailyMissionsState,
|
||||
writeDailyMissionsState,
|
||||
} from '../lib/daily-missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -40,48 +53,32 @@ 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 ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useClaimMissionReward] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
// State is read/written via the in-memory session store in daily-missions.ts.
|
||||
// Kind 11125 content JSON is the persistent source of truth.
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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 +93,139 @@ export function useClaimMissionReward(
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
// Read current missions state from localStorage
|
||||
let missionsState = readMissionsState();
|
||||
// Read current missions state from in-memory session store
|
||||
let missionsState = readDailyMissionsState(user.pubkey);
|
||||
|
||||
// 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(),
|
||||
};
|
||||
|
||||
writeMissionsState(updatedState);
|
||||
const updatedContent = updateDailyMissionsContent(existingContent, 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 in-memory session store for immediate UI feedback ──
|
||||
|
||||
writeDailyMissionsState(user.pubkey, 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) => {
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
/**
|
||||
* useDailyMissions - Hook for managing Blobbi daily missions
|
||||
*
|
||||
* Provides:
|
||||
* - Daily mission state management with localStorage persistence
|
||||
* - Automatic daily reset
|
||||
* - Progress tracking functions
|
||||
* - Read-only access to mission state (claiming is handled by useClaimMissionReward)
|
||||
* - Stage-based filtering (only shows missions user can complete)
|
||||
* - Bonus mission tracking
|
||||
*
|
||||
* Note: Reward claiming should be done via useClaimMissionReward hook,
|
||||
* which persists coins to the kind 11125 Blobbonaut profile.
|
||||
*
|
||||
* ── Source-of-Truth Architecture ──────────────────────────────────────────────
|
||||
*
|
||||
* Kind 11125 content JSON is the ONLY persistent source of truth for
|
||||
* the FULL daily mission state, including intermediate progress.
|
||||
* This hook maintains an in-memory session cache for instant UI updates.
|
||||
*
|
||||
* Hydration flow:
|
||||
* 1. On mount / account switch, check the in-memory session store.
|
||||
* 2. If empty, hydrate from `persistedDailyMissions` (parsed from the
|
||||
* kind 11125 event that the caller provides).
|
||||
* 3. If kind 11125 also has no data, generate fresh missions for today.
|
||||
* 4. During the session, progress/rerolls update the session store.
|
||||
* 5. `useDailyMissionsPersistence` debounces intermediate state changes
|
||||
* (progress, rerolls, daily resets) back to kind 11125.
|
||||
* 6. Claims persist immediately via useClaimMissionReward.
|
||||
* 7. On page refresh the session store is empty → re-hydrates from
|
||||
* kind 11125, which now includes all intermediate progress.
|
||||
*
|
||||
* localStorage is NOT used. This eliminates cross-account leakage.
|
||||
*/
|
||||
|
||||
import { useMemo, useEffect, useState, useCallback } from 'react';
|
||||
import { useMemo, useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import type { PersistedDailyMissions } from '@/blobbi/core/lib/blobbonaut-content';
|
||||
import { persistedMissionToMission } from '@/blobbi/core/lib/blobbonaut-content';
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
type DailyMission,
|
||||
@@ -32,6 +43,8 @@ import {
|
||||
BONUS_MISSION_DEFINITION,
|
||||
getRerollsRemaining,
|
||||
MAX_DAILY_REROLLS,
|
||||
readDailyMissionsState,
|
||||
writeDailyMissionsState,
|
||||
} from '../lib/daily-missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -39,6 +52,13 @@ import {
|
||||
export interface UseDailyMissionsOptions {
|
||||
/** Available Blobbi stages the user has (filters eligible missions) */
|
||||
availableStages?: BlobbiStage[];
|
||||
/**
|
||||
* Persisted daily missions from the kind 11125 profile content.
|
||||
* Pass `profile.content.dailyMissions` here. This is the persistent
|
||||
* source of truth — the hook hydrates from it when the session store
|
||||
* is empty (page refresh, account switch).
|
||||
*/
|
||||
persistedDailyMissions?: PersistedDailyMissions;
|
||||
}
|
||||
|
||||
export interface UseDailyMissionsResult {
|
||||
@@ -52,8 +72,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 */
|
||||
@@ -70,54 +90,57 @@ export interface UseDailyMissionsResult {
|
||||
forceReset: () => void;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useDailyMissions] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDailyMissionsResult {
|
||||
const { availableStages } = options;
|
||||
const { availableStages, persistedDailyMissions } = options;
|
||||
const { user } = useCurrentUser();
|
||||
const pubkey = user?.pubkey;
|
||||
|
||||
// Read state directly from localStorage, with a version counter to trigger re-reads
|
||||
// Version counter to trigger re-reads from the in-memory session store
|
||||
// when external mutations (tracker, reroll, claim) update it.
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
// Read from localStorage on every render when version changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- version is intentionally used to force re-read
|
||||
const state = useMemo(() => readMissionsState(), [version]);
|
||||
|
||||
// Wrapper to write state and update version
|
||||
|
||||
// Track the last pubkey we hydrated for, so we re-hydrate on account switch.
|
||||
const hydratedForPubkey = useRef<string | undefined>(undefined);
|
||||
|
||||
// ── Hydration from kind 11125 ──
|
||||
// When the session store is empty for this pubkey (page refresh, first load,
|
||||
// account switch), hydrate from the persisted kind 11125 data.
|
||||
// This runs synchronously in useMemo so the first render has correct data.
|
||||
const state = useMemo(() => {
|
||||
// Reset hydration tracking on account switch
|
||||
if (pubkey !== hydratedForPubkey.current) {
|
||||
hydratedForPubkey.current = pubkey;
|
||||
}
|
||||
|
||||
// Check session store first
|
||||
const sessionState = readDailyMissionsState(pubkey);
|
||||
if (sessionState) return sessionState;
|
||||
|
||||
// Session store empty — try to hydrate from kind 11125
|
||||
if (pubkey && persistedDailyMissions) {
|
||||
const hydrated = hydrateFromPersisted(persistedDailyMissions);
|
||||
if (hydrated) {
|
||||
writeDailyMissionsState(pubkey, hydrated);
|
||||
return hydrated;
|
||||
}
|
||||
}
|
||||
|
||||
// No persisted data — return null (will be handled below)
|
||||
return null;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- version forces re-read from session store
|
||||
}, [version, pubkey, persistedDailyMissions]);
|
||||
|
||||
// Wrapper to write state to session store and bump version for re-render
|
||||
const setState = useCallback((newState: DailyMissionsState) => {
|
||||
writeMissionsState(newState);
|
||||
writeDailyMissionsState(pubkey, newState);
|
||||
setVersion((v) => v + 1);
|
||||
}, []);
|
||||
}, [pubkey]);
|
||||
|
||||
// Listen for external updates from mutations (reroll, claim, progress tracking)
|
||||
// This re-reads localStorage when other hooks modify it directly
|
||||
useEffect(() => {
|
||||
const handleExternalUpdate = () => {
|
||||
// Bump version to trigger a re-read from localStorage
|
||||
setVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
@@ -130,33 +153,40 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
|
||||
|
||||
// Ensure we have valid state for today
|
||||
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);
|
||||
// Persist the reset state (this will trigger version bump via setState)
|
||||
writeMissionsState(newState);
|
||||
const previousXp = state?.totalXpEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousXp, availableStages);
|
||||
writeDailyMissionsState(pubkey, newState);
|
||||
// Signal persistence hook to write the fresh mission set to kind 11125.
|
||||
// This ensures even newly generated missions survive a page refresh.
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: { source: 'daily-reset' },
|
||||
}));
|
||||
return newState;
|
||||
}
|
||||
|
||||
|
||||
// Migration: ensure rerollsRemaining is set for old state
|
||||
if (state && state.rerollsRemaining === undefined) {
|
||||
const migratedState = {
|
||||
...state,
|
||||
rerollsRemaining: MAX_DAILY_REROLLS,
|
||||
};
|
||||
writeMissionsState(migratedState);
|
||||
writeDailyMissionsState(pubkey, migratedState);
|
||||
// Signal persistence hook to write the migrated state
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: { source: 'migration' },
|
||||
}));
|
||||
return migratedState;
|
||||
}
|
||||
|
||||
|
||||
return state!;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state, pubkey, stagesKey]);
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
@@ -170,18 +200,18 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
|
||||
const noMissionsAvailable = missions.length === 0;
|
||||
const rerollsRemaining = getRerollsRemaining(currentState);
|
||||
const maxRerolls = MAX_DAILY_REROLLS;
|
||||
|
||||
|
||||
// Total potential includes bonus if regular missions exist
|
||||
const basePotentialReward = getTotalPotentialReward(currentState);
|
||||
const totalPotentialReward = missions.length > 0
|
||||
? basePotentialReward + bonusReward
|
||||
const totalPotentialReward = missions.length > 0
|
||||
? basePotentialReward + bonusReward
|
||||
: 0;
|
||||
|
||||
|
||||
// Today's claimed includes bonus if claimed
|
||||
const baseTodayClaimedReward = getTodayClaimedReward(currentState);
|
||||
const todayClaimedReward = baseTodayClaimedReward + (bonusClaimed ? bonusReward : 0);
|
||||
|
||||
const lifetimeCoinsEarned = currentState.totalCoinsEarned;
|
||||
|
||||
const lifetimeXpEarned = currentState.totalXpEarned;
|
||||
|
||||
return {
|
||||
missions,
|
||||
@@ -189,7 +219,7 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
|
||||
allClaimed,
|
||||
totalPotentialReward,
|
||||
todayClaimedReward,
|
||||
lifetimeCoinsEarned,
|
||||
lifetimeXpEarned,
|
||||
bonusAvailable,
|
||||
bonusClaimed,
|
||||
bonusReward,
|
||||
@@ -199,3 +229,26 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
|
||||
forceReset,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Hydration Helper ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert persisted daily missions (from kind 11125 content) to the
|
||||
* runtime DailyMissionsState used by the hooks.
|
||||
*
|
||||
* Returns null if the persisted data is for a different day (stale).
|
||||
*/
|
||||
function hydrateFromPersisted(persisted: PersistedDailyMissions): DailyMissionsState | null {
|
||||
// Only hydrate if the persisted data is for today
|
||||
if (persisted.date !== getTodayDateString()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
date: persisted.date,
|
||||
missions: persisted.missions.map(persistedMissionToMission),
|
||||
totalXpEarned: persisted.totalXpEarned,
|
||||
bonusClaimed: persisted.bonusClaimed,
|
||||
rerollsRemaining: persisted.rerollsRemaining,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* useDailyMissionsPersistence - Debounced persistence of daily mission state to kind 11125
|
||||
*
|
||||
* ── Purpose ──────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Makes kind 11125 the real source of truth for the FULL daily mission state,
|
||||
* including intermediate progress (currentCount, completed flags, rerolls, etc.)
|
||||
* — not just claimed rewards.
|
||||
*
|
||||
* Before this hook, only `useClaimMissionReward` persisted to kind 11125.
|
||||
* Progress tracking and rerolls updated only the in-memory session store and
|
||||
* were lost on page refresh. This hook closes that gap.
|
||||
*
|
||||
* ── How It Works ─────────────────────────────────────────────────────────────
|
||||
*
|
||||
* 1. Listens for `daily-missions-updated` custom DOM events (already
|
||||
* dispatched by the tracker, reroll hook, and claim hook).
|
||||
* 2. On each event, reads the current session store state for the pubkey.
|
||||
* 3. Debounces writes by 2 seconds — if multiple progress ticks fire in
|
||||
* rapid succession, only one Nostr event is published.
|
||||
* 4. Before publishing, compares the state snapshot to the last persisted
|
||||
* snapshot. If nothing changed, the write is skipped entirely.
|
||||
* 5. Uses the standard safe write path: fetchFreshEvent → build
|
||||
* PersistedDailyMissions → updateDailyMissionsContent → publishEvent.
|
||||
* 6. Preserves progression, unknown keys, and all sibling content sections.
|
||||
*
|
||||
* ── What This Hook Does NOT Do ───────────────────────────────────────────────
|
||||
*
|
||||
* • Does NOT replace `useClaimMissionReward`. Claims still persist
|
||||
* immediately (no debounce) because they also award XP to companions.
|
||||
* The claim hook sets a flag on the event detail (`claimed: true`) so
|
||||
* this hook skips the redundant write.
|
||||
* • Does NOT manage UI state. The session store + `useDailyMissions` hook
|
||||
* remain the UI's read path. This hook is write-only.
|
||||
* • Does NOT fire on every render. It only fires in response to real state
|
||||
* changes signaled via the custom DOM event.
|
||||
*
|
||||
* ── Mount Point ──────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Mount once in `BlobbiContent` (BlobbiPage.tsx), alongside `useDailyMissions`.
|
||||
* Requires:
|
||||
* - A logged-in user (pubkey)
|
||||
* - Access to `nostr` (via useNostr) and `publishEvent` (via useNostrPublish)
|
||||
* - The current profile for tag preservation
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
import { KIND_BLOBBONAUT_PROFILE } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
updateDailyMissionsContent,
|
||||
missionToPersistedMission,
|
||||
type PersistedDailyMissions,
|
||||
} from '@/blobbi/core/lib/blobbonaut-content';
|
||||
import { readDailyMissionsState } from '../lib/daily-missions';
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Debounce delay in milliseconds. Batches rapid progress ticks into one write. */
|
||||
const DEBOUNCE_MS = 2_000;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Detail shape for the `daily-missions-updated` custom event. */
|
||||
interface DailyMissionsEventDetail {
|
||||
/** Set to true by useClaimMissionReward — skip redundant persistence. */
|
||||
claimed?: boolean;
|
||||
/** Other fields from various dispatchers (action, count, etc.) */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ─── Snapshot Comparison ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a lightweight fingerprint of the mission state for change detection.
|
||||
* Only includes fields that matter for persistence — avoids false positives
|
||||
* from reference changes in immutable state updates.
|
||||
*/
|
||||
function buildStateFingerprint(persisted: PersistedDailyMissions): string {
|
||||
return JSON.stringify({
|
||||
d: persisted.date,
|
||||
m: persisted.missions.map((m) => `${m.id}:${m.currentCount}:${m.completed}:${m.claimed}`),
|
||||
bc: persisted.bonusClaimed,
|
||||
rr: persisted.rerollsRemaining,
|
||||
xp: persisted.totalXpEarned,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useDailyMissionsPersistence(): void {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
const pubkey = user?.pubkey;
|
||||
|
||||
// Track the last persisted fingerprint to skip no-op writes
|
||||
const lastPersistedFingerprint = useRef<string | null>(null);
|
||||
|
||||
// Debounce timer ref
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Track whether a write is currently in flight to avoid overlapping writes
|
||||
const isWriting = useRef(false);
|
||||
|
||||
// Ref to latest pubkey so the async persist closure always sees the current value
|
||||
const pubkeyRef = useRef(pubkey);
|
||||
pubkeyRef.current = pubkey;
|
||||
|
||||
// Clear fingerprint on account switch so we re-persist for the new account
|
||||
useEffect(() => {
|
||||
lastPersistedFingerprint.current = null;
|
||||
}, [pubkey]);
|
||||
|
||||
/**
|
||||
* Core persist function. Reads session store, builds persisted shape,
|
||||
* checks for changes, then does a safe read-modify-write to kind 11125.
|
||||
*/
|
||||
const persistNow = useCallback(async () => {
|
||||
const currentPubkey = pubkeyRef.current;
|
||||
if (!currentPubkey || isWriting.current) return;
|
||||
|
||||
const state = readDailyMissionsState(currentPubkey);
|
||||
if (!state) return;
|
||||
|
||||
// Build the persisted shape
|
||||
const persisted: PersistedDailyMissions = {
|
||||
date: state.date,
|
||||
missions: state.missions.map(missionToPersistedMission),
|
||||
bonusClaimed: state.bonusClaimed ?? false,
|
||||
rerollsRemaining: state.rerollsRemaining ?? 3,
|
||||
totalXpEarned: state.totalXpEarned,
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Skip if nothing changed since last persist
|
||||
const fingerprint = buildStateFingerprint(persisted);
|
||||
if (fingerprint === lastPersistedFingerprint.current) return;
|
||||
|
||||
isWriting.current = true;
|
||||
try {
|
||||
// Safe read-modify-write: fetch fresh event from relays
|
||||
const freshEvent = await fetchFreshEvent(nostr, {
|
||||
kinds: [KIND_BLOBBONAUT_PROFILE],
|
||||
authors: [currentPubkey],
|
||||
});
|
||||
|
||||
const existingContent = freshEvent?.content ?? '';
|
||||
const existingTags = freshEvent?.tags ?? [];
|
||||
|
||||
// Update only the dailyMissions section, preserving everything else
|
||||
const updatedContent = updateDailyMissionsContent(existingContent, persisted);
|
||||
|
||||
await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: updatedContent,
|
||||
tags: existingTags,
|
||||
prev: freshEvent ?? undefined,
|
||||
});
|
||||
|
||||
// Mark as successfully persisted
|
||||
lastPersistedFingerprint.current = fingerprint;
|
||||
} catch (err) {
|
||||
// Non-fatal — the session store still has the data, and the next
|
||||
// trigger will retry. Don't update the fingerprint so it retries.
|
||||
console.warn('[useDailyMissionsPersistence] Failed to persist:', err);
|
||||
} finally {
|
||||
isWriting.current = false;
|
||||
}
|
||||
}, [nostr, publishEvent]);
|
||||
|
||||
/**
|
||||
* Schedule a debounced persist. Resets the timer on each call so rapid
|
||||
* progress ticks batch into a single write.
|
||||
*/
|
||||
const schedulePersist = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
persistNow();
|
||||
}, DEBOUNCE_MS);
|
||||
}, [persistNow]);
|
||||
|
||||
// Listen for daily-missions-updated events
|
||||
useEffect(() => {
|
||||
if (!pubkey) return;
|
||||
|
||||
const handleUpdate = (e: Event) => {
|
||||
const detail = (e as CustomEvent<DailyMissionsEventDetail>).detail;
|
||||
|
||||
// Skip if the claim hook already persisted (it does its own immediate write)
|
||||
if (detail?.claimed) return;
|
||||
|
||||
schedulePersist();
|
||||
};
|
||||
|
||||
window.addEventListener('daily-missions-updated', handleUpdate);
|
||||
return () => {
|
||||
window.removeEventListener('daily-missions-updated', handleUpdate);
|
||||
// Flush pending write on unmount
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
// Fire-and-forget final persist
|
||||
persistNow();
|
||||
}
|
||||
};
|
||||
}, [pubkey, schedulePersist, persistNow]);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* useRerollMission - Hook for rerolling daily missions
|
||||
*
|
||||
*
|
||||
* Handles:
|
||||
* - Replacing a mission with a new one from the pool
|
||||
* - Tracking reroll usage (max 3 per day)
|
||||
* - Respecting stage-based mission filtering
|
||||
* - Persisting state to localStorage
|
||||
* - Updating the in-memory session store
|
||||
*
|
||||
* Dispatches `daily-missions-updated` after updating the session store.
|
||||
* `useDailyMissionsPersistence` picks this up and debounces the write
|
||||
* to kind 11125, so rerolled state now survives page refresh.
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
@@ -14,7 +18,6 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
type DailyMission,
|
||||
type BlobbiStage,
|
||||
getTodayDateString,
|
||||
@@ -23,6 +26,8 @@ import {
|
||||
rerollMission,
|
||||
canRerollMission,
|
||||
getRerollsRemaining,
|
||||
readDailyMissionsState,
|
||||
writeDailyMissionsState,
|
||||
} from '../lib/daily-missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -38,37 +43,7 @@ export interface RerollMissionResult {
|
||||
rerollsRemaining: number;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
const state = JSON.parse(stored) as DailyMissionsState;
|
||||
|
||||
// Migration: ensure rerollsRemaining is set for old state
|
||||
if (state.rerollsRemaining === undefined) {
|
||||
state.rerollsRemaining = 3; // MAX_DAILY_REROLLS
|
||||
}
|
||||
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useRerollMission] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
// State is read/written via the in-memory session store in daily-missions.ts.
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -87,13 +62,13 @@ export function useRerollMission() {
|
||||
throw new Error('You must be logged in to reroll missions');
|
||||
}
|
||||
|
||||
// Read current missions state from localStorage
|
||||
let missionsState = readMissionsState();
|
||||
// Read current missions state from in-memory session store
|
||||
let missionsState = readDailyMissionsState(user.pubkey);
|
||||
|
||||
// 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
|
||||
@@ -118,8 +93,8 @@ export function useRerollMission() {
|
||||
throw new Error('No replacement missions available. All alternative missions may already be in your daily list.');
|
||||
}
|
||||
|
||||
// Persist the updated state
|
||||
writeMissionsState(result.state);
|
||||
// Update the in-memory session store
|
||||
writeDailyMissionsState(user.pubkey, result.state);
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
|
||||
@@ -156,6 +156,7 @@ export {
|
||||
// Daily Missions
|
||||
export { useDailyMissions } from './hooks/useDailyMissions';
|
||||
export type { UseDailyMissionsResult } from './hooks/useDailyMissions';
|
||||
export { useDailyMissionsPersistence } from './hooks/useDailyMissionsPersistence';
|
||||
export { useClaimMissionReward } from './hooks/useClaimMissionReward';
|
||||
export type { ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
|
||||
export {
|
||||
|
||||
@@ -1,109 +1,79 @@
|
||||
/**
|
||||
* Daily Mission Tracker - Standalone progress tracking utility
|
||||
*
|
||||
*
|
||||
* This module provides a simple way to track daily mission progress
|
||||
* without requiring React hooks or context. It directly manipulates
|
||||
* localStorage for immediate persistence.
|
||||
*
|
||||
* This approach allows action hooks (which may be called outside of
|
||||
* the daily missions hook context) to record progress.
|
||||
* without requiring React hooks or context. It reads/writes the
|
||||
* in-memory session store for immediate updates.
|
||||
*
|
||||
* ── Source of Truth ───────────────────────────────────────────────────────────
|
||||
*
|
||||
* The in-memory session store (in daily-missions.ts) holds the current
|
||||
* session's mission state. Kind 11125 content JSON is the persistent
|
||||
* source of truth.
|
||||
*
|
||||
* This tracker updates the session store and dispatches
|
||||
* `daily-missions-updated` custom DOM events. The `useDailyMissionsPersistence`
|
||||
* hook listens for these events and debounces writes to kind 11125, so
|
||||
* intermediate progress survives page refresh.
|
||||
*/
|
||||
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
type DailyMissionAction,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
updateMissionProgress,
|
||||
readDailyMissionsState,
|
||||
writeDailyMissionsState,
|
||||
} from './daily-missions';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the current daily missions state from localStorage
|
||||
*/
|
||||
function readState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the daily missions state to localStorage
|
||||
*/
|
||||
function writeState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[DailyMissionTracker] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we have a valid state for today, creating one if necessary
|
||||
*/
|
||||
function ensureCurrentState(pubkey?: string): DailyMissionsState {
|
||||
const current = readState();
|
||||
|
||||
if (needsDailyReset(current)) {
|
||||
const previousCoins = current?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins);
|
||||
writeState(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
return current!;
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Record progress for a daily mission action.
|
||||
* This function can be called from anywhere (hooks, event handlers, etc.)
|
||||
* and will immediately persist to localStorage.
|
||||
*
|
||||
* and will immediately update the in-memory session store.
|
||||
*
|
||||
* No-ops silently if:
|
||||
* - pubkey is not provided (logged-out users don't track)
|
||||
* - no session state exists yet for this pubkey (hook hasn't hydrated)
|
||||
*
|
||||
* @param action - The action type that was performed
|
||||
* @param count - Number of times the action was performed (default: 1)
|
||||
* @param pubkey - Optional user pubkey for personalized mission selection
|
||||
* @param pubkey - User pubkey (required for account-scoped state)
|
||||
*/
|
||||
export function trackDailyMissionProgress(
|
||||
action: DailyMissionAction,
|
||||
count: number = 1,
|
||||
pubkey?: string
|
||||
): void {
|
||||
const current = ensureCurrentState(pubkey);
|
||||
const current = readDailyMissionsState(pubkey);
|
||||
if (!current) return;
|
||||
|
||||
const updated = updateMissionProgress(current, action, count);
|
||||
writeState(updated);
|
||||
|
||||
// Dispatch a custom event so React components can re-render if needed
|
||||
writeDailyMissionsState(pubkey, updated);
|
||||
|
||||
// Dispatch a custom event so React components can re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { action, count } }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to track multiple actions at once.
|
||||
* Useful when an action should count toward multiple missions.
|
||||
*
|
||||
*
|
||||
* No-ops silently if pubkey is not provided or no session state exists.
|
||||
*
|
||||
* @param actions - Array of actions to track
|
||||
* @param pubkey - Optional user pubkey
|
||||
* @param pubkey - User pubkey (required for account-scoped state)
|
||||
*/
|
||||
export function trackMultipleDailyMissionActions(
|
||||
actions: DailyMissionAction[],
|
||||
pubkey?: string
|
||||
): void {
|
||||
let current = ensureCurrentState(pubkey);
|
||||
|
||||
let current = readDailyMissionsState(pubkey);
|
||||
if (!current) return;
|
||||
|
||||
for (const action of actions) {
|
||||
current = updateMissionProgress(current, action, 1);
|
||||
}
|
||||
|
||||
writeState(current);
|
||||
|
||||
writeDailyMissionsState(pubkey, current);
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { actions } }));
|
||||
}
|
||||
|
||||
@@ -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,21 @@ export interface DailyMission extends DailyMissionDefinition {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored state for daily missions (persisted in localStorage)
|
||||
* Stored state for daily missions.
|
||||
*
|
||||
* Source of truth: Kind 11125 profile content JSON (`dailyMissions` section).
|
||||
* During a session, state is held in an in-memory map for instant UI updates.
|
||||
* `useDailyMissionsPersistence` debounces all intermediate state changes
|
||||
* (progress, rerolls, daily resets) back to kind 11125, so nothing is lost
|
||||
* on page refresh. localStorage is NOT used.
|
||||
*/
|
||||
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) */
|
||||
@@ -81,6 +87,68 @@ export interface DailyMissionsState {
|
||||
/** Maximum number of mission rerolls allowed per day */
|
||||
export const MAX_DAILY_REROLLS = 3;
|
||||
|
||||
// ─── In-Memory Session Store ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* In-memory, pubkey-scoped store for daily missions state.
|
||||
*
|
||||
* ── Source-of-Truth Architecture ──────────────────────────────────────────────
|
||||
*
|
||||
* Kind 11125 content JSON (`dailyMissions` section) is the ONLY persistent
|
||||
* source of truth. This in-memory map is a short-lived UI cache:
|
||||
*
|
||||
* • On page load / account switch, `useDailyMissions` hydrates this map
|
||||
* from `profile.content.dailyMissions` (parsed from the kind 11125 event).
|
||||
* • During the session, progress/rerolls update this map for instant UI.
|
||||
* • `useDailyMissionsPersistence` debounces writes of intermediate progress
|
||||
* (currentCount, completed, rerolls, etc.) back to kind 11125.
|
||||
* • Claims persist to kind 11125 immediately via `useClaimMissionReward`.
|
||||
* • On page refresh the map is empty, so the hook re-hydrates from kind 11125
|
||||
* — which now includes intermediate progress, not just claimed rewards.
|
||||
*
|
||||
* localStorage is NOT used for daily missions. This eliminates all
|
||||
* cross-account leakage bugs.
|
||||
*/
|
||||
const sessionStore = new Map<string, DailyMissionsState>();
|
||||
|
||||
/**
|
||||
* Read daily missions state from the in-memory session store.
|
||||
*
|
||||
* Returns null if:
|
||||
* - No state exists for this pubkey in the current session
|
||||
* - The pubkey is empty/undefined
|
||||
*/
|
||||
export function readDailyMissionsState(pubkey: string | undefined): DailyMissionsState | null {
|
||||
if (!pubkey) return null;
|
||||
return sessionStore.get(pubkey) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write daily missions state to the in-memory session store.
|
||||
*
|
||||
* This is the ONLY correct way to update session mission state.
|
||||
* No-ops silently if pubkey is empty/undefined (logged-out users
|
||||
* should not have mission state).
|
||||
*
|
||||
* Note: This does NOT persist to kind 11125 by itself. Callers
|
||||
* should dispatch a `daily-missions-updated` DOM event after writing
|
||||
* so that `useDailyMissionsPersistence` picks up the change and
|
||||
* debounces the write to kind 11125.
|
||||
*/
|
||||
export function writeDailyMissionsState(pubkey: string | undefined, state: DailyMissionsState): void {
|
||||
if (!pubkey) return;
|
||||
sessionStore.set(pubkey, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the session store entry for a pubkey.
|
||||
* Used when the hook needs to re-hydrate from kind 11125 data.
|
||||
*/
|
||||
export function clearDailyMissionsState(pubkey: string | undefined): void {
|
||||
if (!pubkey) return;
|
||||
sessionStore.delete(pubkey);
|
||||
}
|
||||
|
||||
// ─── Mission Pool ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -104,7 +172,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 +182,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 +194,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 +204,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 +214,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 +226,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 +238,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 +248,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 +265,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 +275,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 +287,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 +297,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 +309,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 +319,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 +341,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 +472,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 +528,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 +537,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 +548,9 @@ export function claimMissionReward(
|
||||
state: {
|
||||
...state,
|
||||
missions: updatedMissions,
|
||||
totalCoinsEarned: state.totalCoinsEarned + coinsEarned,
|
||||
totalXpEarned: state.totalXpEarned + xpEarned,
|
||||
},
|
||||
coinsEarned,
|
||||
xpEarned,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -526,10 +593,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 +620,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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ export function useBlobbiMigration() {
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
content: profile.event.content,
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1140,6 +1147,8 @@ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([
|
||||
*/
|
||||
export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([
|
||||
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'onboarding_done', 'has', 'storage',
|
||||
// Progression: derived global level mirrored into a tag for relay queryability
|
||||
'level',
|
||||
// Legacy player progress tags (preserved for compatibility)
|
||||
'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis',
|
||||
'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi',
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
// src/blobbi/core/lib/blobbonaut-content.ts
|
||||
|
||||
/**
|
||||
* Blobbonaut Profile Content JSON — Type definitions, parsing, and serialization.
|
||||
*
|
||||
* Kind 11125 uses a JSON content field alongside tag-based data. The content
|
||||
* field holds independent top-level sections that coexist without interference:
|
||||
*
|
||||
* {
|
||||
* "dailyMissions": { ... },
|
||||
* "progression": { ... },
|
||||
* "<future>": { ... }
|
||||
* }
|
||||
*
|
||||
* ── Source-of-Truth Rules ─────────────────────────────────────────────────────
|
||||
*
|
||||
* • `dailyMissions` is an independent top-level section. It is only modified
|
||||
* by daily mission write paths through `updateDailyMissionsContent()`.
|
||||
*
|
||||
* • `progression` is an independent top-level section. It is only modified
|
||||
* by progression write paths through `updateProgressionContent()` (in
|
||||
* progression.ts). Within `progression`:
|
||||
* – `progression.games.*` is the source of truth for per-game levels/XP.
|
||||
* – `progression.global.level` is derived (sum of all game levels).
|
||||
* – The `["level", "<n>"]` tag is a queryable mirror of the derived level.
|
||||
*
|
||||
* • Unknown top-level keys are always preserved. Future features (inventory,
|
||||
* settings, achievements, etc.) can safely add new top-level sections
|
||||
* without risk of being overwritten.
|
||||
*
|
||||
* ── How to Write Content Safely ───────────────────────────────────────────────
|
||||
*
|
||||
* NEVER manually reconstruct the full content object. Always use one of the
|
||||
* section-specific helpers:
|
||||
*
|
||||
* • `updateDailyMissionsContent(existingContent, missions)` — for daily missions
|
||||
* • `updateProgressionContent(existingContent, update)` — for progression
|
||||
* • `updateContentSection(existingContent, key, value)` — for any section
|
||||
*
|
||||
* These helpers guarantee:
|
||||
* 1. Existing content is parsed safely (invalid JSON → empty object + warning)
|
||||
* 2. Only the targeted section is modified
|
||||
* 3. All sibling sections and unknown keys are preserved
|
||||
* 4. The result is serialized back to a valid JSON string
|
||||
*
|
||||
* Tag-only write paths (shop purchases, onboarding, etc.) that do not modify
|
||||
* the content field should pass `profile.event.content` through unchanged.
|
||||
*
|
||||
* 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';
|
||||
import type { Progression } from './progression';
|
||||
import { parseProgression } from './progression';
|
||||
import { safeParseContent, updateContentSection } from './content-json';
|
||||
|
||||
// Re-export shared utilities so existing importers don't need to change.
|
||||
// The canonical home is content-json.ts; these re-exports keep the public
|
||||
// API backward-compatible.
|
||||
export type { ParsedContentResult } from './content-json';
|
||||
export { safeParseContent, updateContentSection } from './content-json';
|
||||
|
||||
// ─── 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.
|
||||
*
|
||||
* Each field is an independent section. New top-level fields can be added
|
||||
* here as the system grows (inventory, settings, achievements, etc.).
|
||||
*
|
||||
* Unknown fields from the raw JSON are preserved via `RawProfileContent`
|
||||
* during read-modify-write to avoid losing data from future versions.
|
||||
*/
|
||||
export interface BlobbonautProfileContent {
|
||||
/** Daily missions state. Undefined if never migrated. */
|
||||
dailyMissions?: PersistedDailyMissions;
|
||||
/** Progression system (global level + per-game levels/XP/unlocks). Undefined if not yet initialized. */
|
||||
progression?: Progression;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal representation that also carries unknown fields for safe merging.
|
||||
* Every parse and merge operation works on this type to ensure forward
|
||||
* compatibility — keys we don't recognize are never dropped.
|
||||
*/
|
||||
interface RawProfileContent extends BlobbonautProfileContent {
|
||||
/** Captures any fields we don't recognize, for forward compatibility */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ─── Typed Parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse the content field of a Kind 11125 event into structured, typed data.
|
||||
*
|
||||
* - Empty string or invalid JSON returns empty object (no dailyMissions,
|
||||
* no progression).
|
||||
* - Malformed sections are silently dropped (not propagated as corrupt data).
|
||||
* - Unknown top-level fields are preserved in the return value for forward
|
||||
* compatibility.
|
||||
*
|
||||
* Use this when you need typed access to content fields (e.g. reading
|
||||
* `profile.content.dailyMissions`). For write operations, use the
|
||||
* section-specific update helpers instead.
|
||||
*/
|
||||
export function parseProfileContent(content: string): RawProfileContent {
|
||||
const { data } = safeParseContent(content);
|
||||
|
||||
// Start with all keys (including unknown ones)
|
||||
const result: RawProfileContent = { ...data };
|
||||
|
||||
// ── Validate dailyMissions ──
|
||||
if (data.dailyMissions) {
|
||||
const dm = data.dailyMissions;
|
||||
if (
|
||||
typeof dm === 'object' &&
|
||||
dm !== null &&
|
||||
!Array.isArray(dm) &&
|
||||
typeof (dm as Record<string, unknown>).date === 'string' &&
|
||||
Array.isArray((dm as Record<string, unknown>).missions)
|
||||
) {
|
||||
const dmObj = dm as Record<string, unknown>;
|
||||
result.dailyMissions = {
|
||||
date: dmObj.date as string,
|
||||
missions: (dmObj.missions as unknown[]).filter(isValidPersistedMission),
|
||||
bonusClaimed: dmObj.bonusClaimed === true,
|
||||
rerollsRemaining: typeof dmObj.rerollsRemaining === 'number' ? dmObj.rerollsRemaining : 3,
|
||||
totalXpEarned: typeof dmObj.totalXpEarned === 'number' ? dmObj.totalXpEarned : 0,
|
||||
lastUpdatedAt: typeof dmObj.lastUpdatedAt === 'number' ? dmObj.lastUpdatedAt : 0,
|
||||
};
|
||||
} else {
|
||||
// Malformed — drop it rather than persisting corrupt data
|
||||
delete result.dailyMissions;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validate progression ──
|
||||
// parseProgression returns undefined for malformed data, which safely
|
||||
// removes the key rather than persisting corrupt structures.
|
||||
if (data.progression !== undefined) {
|
||||
const parsed = parseProgression(data.progression);
|
||||
if (parsed) {
|
||||
result.progression = parsed;
|
||||
} else {
|
||||
delete result.progression;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Daily Missions Content Update ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update the `dailyMissions` section inside a kind 11125 content string.
|
||||
*
|
||||
* This is the **standard entry point** for any code path that needs to
|
||||
* persist daily mission state. It:
|
||||
*
|
||||
* 1. Parses the existing content safely (empty/invalid → empty object)
|
||||
* 2. Replaces only the `dailyMissions` key
|
||||
* 3. Preserves `progression`, unknown keys, and all other sibling sections
|
||||
* 4. Returns the serialized content string
|
||||
*
|
||||
* ── Why this function should be the standard path ──
|
||||
*
|
||||
* Every kind 11125 content write that touches `dailyMissions` should flow
|
||||
* through `updateDailyMissionsContent`. This guarantees:
|
||||
* - `progression` is never overwritten
|
||||
* - Unknown top-level keys are never dropped
|
||||
* - Future sections (inventory, settings, achievements) are safe
|
||||
* - The merge is always conservative and section-scoped
|
||||
*
|
||||
* @param existingContent - The current `event.content` string (may be empty)
|
||||
* @param dailyMissions - The complete daily missions state to persist
|
||||
* @returns The serialized content string with dailyMissions updated
|
||||
*/
|
||||
export function updateDailyMissionsContent(
|
||||
existingContent: string,
|
||||
dailyMissions: PersistedDailyMissions,
|
||||
): string {
|
||||
return updateContentSection(existingContent, 'dailyMissions', dailyMissions);
|
||||
}
|
||||
|
||||
// ─── Legacy Merge (deprecated) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serialize profile content to a JSON string for the event content field.
|
||||
*
|
||||
* @deprecated Use the section-specific helpers instead:
|
||||
* - `updateDailyMissionsContent(existingContent, missions)` for daily missions
|
||||
* - `updateProgressionContent(existingContent, update)` for progression (from progression.ts)
|
||||
* - `updateContentSection(existingContent, key, value)` for generic sections
|
||||
*
|
||||
* This function performs a shallow merge which is safe for flat sections
|
||||
* like `dailyMissions` but NOT safe for nested sections like `progression`
|
||||
* (which requires deep merging to avoid dropping sibling game entries).
|
||||
*
|
||||
* Kept for backward compatibility but should not be used in new code.
|
||||
*/
|
||||
export function mergeProfileContent(
|
||||
existingContent: string,
|
||||
updates: Partial<BlobbonautProfileContent>,
|
||||
): string {
|
||||
const { data } = safeParseContent(existingContent);
|
||||
|
||||
// Shallow merge — safe for flat sections, not for nested ones.
|
||||
const merged: Record<string, unknown> = {
|
||||
...data,
|
||||
...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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
// src/blobbi/core/lib/content-coexistence.test.ts
|
||||
|
||||
/**
|
||||
* Coexistence tests for the kind 11125 content system.
|
||||
*
|
||||
* These tests verify the critical guarantee that independent content sections
|
||||
* (dailyMissions, progression, unknown/future keys) can be updated without
|
||||
* interfering with each other. Every test here represents an invariant that
|
||||
* must never be broken.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { safeParseContent, updateContentSection } from './content-json';
|
||||
import {
|
||||
parseProfileContent,
|
||||
updateDailyMissionsContent,
|
||||
type PersistedDailyMissions,
|
||||
} from './blobbonaut-content';
|
||||
import { updateProgressionContent, upsertLevelTag } from './progression';
|
||||
|
||||
// ─── Test Fixtures ────────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_DAILY_MISSIONS: PersistedDailyMissions = {
|
||||
date: '2026-04-06',
|
||||
missions: [
|
||||
{
|
||||
id: 'feed_3',
|
||||
title: 'Feed your Blobbi',
|
||||
description: 'Feed your Blobbi 3 times',
|
||||
action: 'feed',
|
||||
requiredCount: 3,
|
||||
reward: 50,
|
||||
weight: 1,
|
||||
currentCount: 2,
|
||||
completed: false,
|
||||
claimed: false,
|
||||
},
|
||||
],
|
||||
bonusClaimed: false,
|
||||
rerollsRemaining: 2,
|
||||
totalXpEarned: 150,
|
||||
lastUpdatedAt: 1712400000000,
|
||||
};
|
||||
|
||||
const SAMPLE_PROGRESSION_JSON = {
|
||||
global: { level: 5, xp: 0 },
|
||||
games: {
|
||||
blobbi: {
|
||||
level: 3,
|
||||
xp: 250,
|
||||
unlocks: { maxBlobbis: 2, realInventoryEnabled: true },
|
||||
},
|
||||
farm: { level: 2, xp: 100 },
|
||||
},
|
||||
};
|
||||
|
||||
const SAMPLE_UNKNOWN_SECTION = { achievements: ['first_hatch', 'level_5'] };
|
||||
|
||||
/** Build a full content string with all sections present. */
|
||||
function buildFullContent(): string {
|
||||
return JSON.stringify({
|
||||
dailyMissions: SAMPLE_DAILY_MISSIONS,
|
||||
progression: SAMPLE_PROGRESSION_JSON,
|
||||
futureFeature: SAMPLE_UNKNOWN_SECTION,
|
||||
settings: { theme: 'dark', language: 'en' },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Progression ↔ DailyMissions Coexistence ──────────────────────────────────
|
||||
|
||||
describe('progression and dailyMissions coexistence', () => {
|
||||
it('updating progression preserves dailyMissions exactly', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const { content } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { level: 4, xp: 300 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
});
|
||||
|
||||
it('updating dailyMissions preserves progression exactly', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const updatedMissions: PersistedDailyMissions = {
|
||||
...SAMPLE_DAILY_MISSIONS,
|
||||
totalXpEarned: 200,
|
||||
lastUpdatedAt: 9999999999999,
|
||||
};
|
||||
|
||||
const content = updateDailyMissionsContent(existing, updatedMissions);
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
// Progression must be untouched
|
||||
expect(parsed.progression).toEqual(SAMPLE_PROGRESSION_JSON);
|
||||
// dailyMissions must reflect the update
|
||||
expect(parsed.dailyMissions.totalXpEarned).toBe(200);
|
||||
});
|
||||
|
||||
it('updating progression then dailyMissions preserves both updates', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
// First: update progression
|
||||
const { content: afterProgression } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { level: 10 } },
|
||||
});
|
||||
|
||||
// Second: update daily missions on top of the progression-updated content
|
||||
const updatedMissions: PersistedDailyMissions = {
|
||||
...SAMPLE_DAILY_MISSIONS,
|
||||
bonusClaimed: true,
|
||||
};
|
||||
const afterBoth = updateDailyMissionsContent(afterProgression, updatedMissions);
|
||||
|
||||
const parsed = JSON.parse(afterBoth);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(10);
|
||||
expect(parsed.progression.global.level).toBe(12); // 10 + 2 (farm)
|
||||
expect(parsed.dailyMissions.bonusClaimed).toBe(true);
|
||||
});
|
||||
|
||||
it('updating dailyMissions then progression preserves both updates', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
// First: update daily missions
|
||||
const updatedMissions: PersistedDailyMissions = {
|
||||
...SAMPLE_DAILY_MISSIONS,
|
||||
rerollsRemaining: 0,
|
||||
};
|
||||
const afterMissions = updateDailyMissionsContent(existing, updatedMissions);
|
||||
|
||||
// Second: update progression on top of the missions-updated content
|
||||
const { content: afterBoth } = updateProgressionContent(afterMissions, {
|
||||
games: { blobbi: { xp: 500 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(afterBoth);
|
||||
expect(parsed.dailyMissions.rerollsRemaining).toBe(0);
|
||||
expect(parsed.progression.games.blobbi.xp).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sibling Game Preservation ────────────────────────────────────────────────
|
||||
|
||||
describe('progression.games sibling preservation', () => {
|
||||
it('updating blobbi preserves sibling future games', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const { content } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { level: 7 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.games.farm).toEqual({ level: 2, xp: 100 });
|
||||
expect(parsed.progression.games.blobbi.level).toBe(7);
|
||||
});
|
||||
|
||||
it('adding a new game preserves all existing games', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const { content } = updateProgressionContent(existing, {
|
||||
games: { racing: { level: 1, xp: 0 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(3);
|
||||
expect(parsed.progression.games.farm).toEqual({ level: 2, xp: 100 });
|
||||
expect(parsed.progression.games.racing.level).toBe(1);
|
||||
expect(parsed.progression.global.level).toBe(6); // 3 + 2 + 1
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Unknown Key Preservation ─────────────────────────────────────────────────
|
||||
|
||||
describe('unknown top-level key preservation', () => {
|
||||
it('updating progression preserves unknown keys', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const { content } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { xp: 999 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.futureFeature).toEqual(SAMPLE_UNKNOWN_SECTION);
|
||||
expect(parsed.settings).toEqual({ theme: 'dark', language: 'en' });
|
||||
});
|
||||
|
||||
it('updating dailyMissions preserves unknown keys', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const content = updateDailyMissionsContent(existing, {
|
||||
...SAMPLE_DAILY_MISSIONS,
|
||||
totalXpEarned: 500,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.futureFeature).toEqual(SAMPLE_UNKNOWN_SECTION);
|
||||
expect(parsed.settings).toEqual({ theme: 'dark', language: 'en' });
|
||||
});
|
||||
|
||||
it('updateContentSection preserves all sibling keys', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const content = updateContentSection(existing, 'inventory', { items: ['potion'] });
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
expect(parsed.progression).toEqual(SAMPLE_PROGRESSION_JSON);
|
||||
expect(parsed.futureFeature).toEqual(SAMPLE_UNKNOWN_SECTION);
|
||||
expect(parsed.inventory).toEqual({ items: ['potion'] });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Level Tag Isolation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('level tag does not affect unrelated tags', () => {
|
||||
it('upsertLevelTag preserves all other tags', () => {
|
||||
const tags = [
|
||||
['d', 'blobbonaut-abc123'],
|
||||
['b', 'blobbi:ecosystem:v1'],
|
||||
['name', 'TestPlayer'],
|
||||
['coins', '500'],
|
||||
['has', 'blobbi-001'],
|
||||
['has', 'blobbi-002'],
|
||||
['storage', 'potion:3'],
|
||||
['current_companion', 'blobbi-001'],
|
||||
['blobbi_onboarding_done', 'true'],
|
||||
];
|
||||
|
||||
const result = upsertLevelTag(tags, 5);
|
||||
|
||||
// All original tags preserved in order
|
||||
expect(result.slice(0, 9)).toEqual(tags);
|
||||
// Level appended
|
||||
expect(result[9]).toEqual(['level', '5']);
|
||||
// Total length: original + 1
|
||||
expect(result).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('updating existing level tag does not change tag order', () => {
|
||||
const tags = [
|
||||
['d', 'blobbonaut-abc123'],
|
||||
['level', '3'],
|
||||
['name', 'TestPlayer'],
|
||||
['coins', '500'],
|
||||
];
|
||||
|
||||
const result = upsertLevelTag(tags, 7);
|
||||
|
||||
expect(result).toEqual([
|
||||
['d', 'blobbonaut-abc123'],
|
||||
['level', '7'],
|
||||
['name', 'TestPlayer'],
|
||||
['coins', '500'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('level tag always mirrors derived global level', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
// Update Blobbi level from 3 to 8
|
||||
const { content, globalLevel } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { level: 8 } },
|
||||
});
|
||||
|
||||
// Global = blobbi(8) + farm(2) = 10
|
||||
expect(globalLevel).toBe(10);
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.global.level).toBe(10);
|
||||
|
||||
// upsertLevelTag mirrors this
|
||||
const tags = upsertLevelTag([['d', 'test']], globalLevel);
|
||||
expect(tags).toContainEqual(['level', '10']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Malformed Data Safety ────────────────────────────────────────────────────
|
||||
|
||||
describe('malformed progression is safely dropped', () => {
|
||||
it('parseProfileContent drops malformed progression (no games key)', () => {
|
||||
const content = JSON.stringify({
|
||||
dailyMissions: SAMPLE_DAILY_MISSIONS,
|
||||
progression: { global: { level: 5, xp: 0 } }, // Missing 'games'
|
||||
});
|
||||
|
||||
const parsed = parseProfileContent(content);
|
||||
expect(parsed.dailyMissions).toBeDefined();
|
||||
expect(parsed.progression).toBeUndefined(); // Dropped
|
||||
});
|
||||
|
||||
it('parseProfileContent drops non-object progression', () => {
|
||||
const content = JSON.stringify({
|
||||
dailyMissions: SAMPLE_DAILY_MISSIONS,
|
||||
progression: 'not-an-object',
|
||||
});
|
||||
|
||||
const parsed = parseProfileContent(content);
|
||||
expect(parsed.dailyMissions).toBeDefined();
|
||||
expect(parsed.progression).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updateProgressionContent still works after malformed progression', () => {
|
||||
const content = JSON.stringify({
|
||||
dailyMissions: SAMPLE_DAILY_MISSIONS,
|
||||
progression: 42, // Malformed
|
||||
});
|
||||
|
||||
const { content: updated, globalLevel } = updateProgressionContent(content, {
|
||||
games: { blobbi: { level: 1, xp: 0 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(updated);
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(1);
|
||||
expect(globalLevel).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed dailyMissions is safely dropped', () => {
|
||||
it('parseProfileContent drops malformed dailyMissions (missing date)', () => {
|
||||
const content = JSON.stringify({
|
||||
dailyMissions: { missions: [], bonusClaimed: false }, // Missing 'date'
|
||||
progression: SAMPLE_PROGRESSION_JSON,
|
||||
});
|
||||
|
||||
const parsed = parseProfileContent(content);
|
||||
expect(parsed.dailyMissions).toBeUndefined(); // Dropped
|
||||
expect(parsed.progression).toBeDefined();
|
||||
});
|
||||
|
||||
it('parseProfileContent drops non-object dailyMissions', () => {
|
||||
const content = JSON.stringify({
|
||||
dailyMissions: 'corrupted',
|
||||
progression: SAMPLE_PROGRESSION_JSON,
|
||||
});
|
||||
|
||||
const parsed = parseProfileContent(content);
|
||||
expect(parsed.dailyMissions).toBeUndefined();
|
||||
expect(parsed.progression).toBeDefined();
|
||||
});
|
||||
|
||||
it('updateDailyMissionsContent replaces malformed dailyMissions', () => {
|
||||
const content = JSON.stringify({
|
||||
dailyMissions: null, // Malformed
|
||||
progression: SAMPLE_PROGRESSION_JSON,
|
||||
});
|
||||
|
||||
const updated = updateDailyMissionsContent(content, SAMPLE_DAILY_MISSIONS);
|
||||
const parsed = JSON.parse(updated);
|
||||
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
expect(parsed.progression).toEqual(SAMPLE_PROGRESSION_JSON);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Invalid JSON Content ─────────────────────────────────────────────────────
|
||||
|
||||
describe('invalid JSON content does not crash', () => {
|
||||
it('safeParseContent returns parseOk: false for invalid JSON', () => {
|
||||
const result = safeParseContent('not valid json {{{}}}');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('safeParseContent returns parseOk: false for array JSON', () => {
|
||||
const result = safeParseContent('[1, 2, 3]');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('safeParseContent returns parseOk: false for string JSON', () => {
|
||||
const result = safeParseContent('"just a string"');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('safeParseContent returns parseOk: true for empty string', () => {
|
||||
const result = safeParseContent('');
|
||||
expect(result.parseOk).toBe(true);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('safeParseContent returns parseOk: true for whitespace-only', () => {
|
||||
const result = safeParseContent(' \n\t ');
|
||||
expect(result.parseOk).toBe(true);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('safeParseContent returns parseOk: true for valid JSON object', () => {
|
||||
const result = safeParseContent('{"hello": "world"}');
|
||||
expect(result.parseOk).toBe(true);
|
||||
expect(result.data).toEqual({ hello: 'world' });
|
||||
});
|
||||
|
||||
it('updateProgressionContent works on invalid JSON', () => {
|
||||
const { content, globalLevel } = updateProgressionContent('{{bad}}', {
|
||||
games: { blobbi: { level: 2, xp: 50 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(2);
|
||||
expect(globalLevel).toBe(2);
|
||||
// No dailyMissions because input was corrupt
|
||||
expect(parsed.dailyMissions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updateDailyMissionsContent works on invalid JSON', () => {
|
||||
const content = updateDailyMissionsContent('not json!!!', SAMPLE_DAILY_MISSIONS);
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
// No progression because input was corrupt
|
||||
expect(parsed.progression).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parseProfileContent returns empty object for invalid JSON', () => {
|
||||
const parsed = parseProfileContent('corrupted {{{');
|
||||
expect(parsed).toEqual({});
|
||||
expect(parsed.dailyMissions).toBeUndefined();
|
||||
expect(parsed.progression).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Global Level Derivation ──────────────────────────────────────────────────
|
||||
|
||||
describe('global.level is always derived from games.*', () => {
|
||||
it('global.level equals sum of game levels after update', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const { content, globalLevel } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { level: 10 } },
|
||||
});
|
||||
|
||||
// blobbi(10) + farm(2) = 12
|
||||
expect(globalLevel).toBe(12);
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.global.level).toBe(12);
|
||||
});
|
||||
|
||||
it('global.level is re-derived even if caller passes a value', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const { content, globalLevel } = updateProgressionContent(existing, {
|
||||
global: { level: 999 }, // Should be ignored
|
||||
games: { blobbi: { level: 1 } },
|
||||
});
|
||||
|
||||
// blobbi(1) + farm(2) = 3
|
||||
expect(globalLevel).toBe(3);
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.global.level).toBe(3);
|
||||
});
|
||||
|
||||
it('global.level reflects new game added', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const { globalLevel } = updateProgressionContent(existing, {
|
||||
games: { racing: { level: 5, xp: 0 } },
|
||||
});
|
||||
|
||||
// blobbi(3) + farm(2) + racing(5) = 10
|
||||
expect(globalLevel).toBe(10);
|
||||
});
|
||||
|
||||
it('parseProfileContent re-derives global.level from stored data', () => {
|
||||
// Stored content has wrong global level
|
||||
const content = JSON.stringify({
|
||||
progression: {
|
||||
global: { level: 999, xp: 0 }, // Wrong!
|
||||
games: {
|
||||
blobbi: { level: 2, xp: 0, unlocks: { maxBlobbis: 1, realInventoryEnabled: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = parseProfileContent(content);
|
||||
// Re-derived: only blobbi at level 2
|
||||
expect(parsed.progression!.global.level).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Scalability for Future Sections ──────────────────────────────────────────
|
||||
|
||||
describe('scalability for future sections', () => {
|
||||
it('updateContentSection can add arbitrary new sections', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
let content = updateContentSection(existing, 'inventory', { slots: 10, items: [] });
|
||||
content = updateContentSection(content, 'achievements', ['first_hatch']);
|
||||
content = updateContentSection(content, 'settings', { theme: 'light' });
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.inventory).toEqual({ slots: 10, items: [] });
|
||||
expect(parsed.achievements).toEqual(['first_hatch']);
|
||||
expect(parsed.settings).toEqual({ theme: 'light' });
|
||||
// Original sections preserved
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
expect(parsed.progression).toEqual(SAMPLE_PROGRESSION_JSON);
|
||||
});
|
||||
|
||||
it('section-specific helpers preserve arbitrary new sections', () => {
|
||||
// Start with custom sections
|
||||
const existing = JSON.stringify({
|
||||
dailyMissions: SAMPLE_DAILY_MISSIONS,
|
||||
progression: SAMPLE_PROGRESSION_JSON,
|
||||
inventory: { slots: 10, items: ['potion'] },
|
||||
achievements: ['first_hatch', 'level_5'],
|
||||
leaderboard: { rank: 42 },
|
||||
});
|
||||
|
||||
// Update progression
|
||||
const { content: afterProg } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { xp: 999 } },
|
||||
});
|
||||
|
||||
// Update daily missions
|
||||
const afterMissions = updateDailyMissionsContent(afterProg, {
|
||||
...SAMPLE_DAILY_MISSIONS,
|
||||
totalXpEarned: 9999,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(afterMissions);
|
||||
expect(parsed.inventory).toEqual({ slots: 10, items: ['potion'] });
|
||||
expect(parsed.achievements).toEqual(['first_hatch', 'level_5']);
|
||||
expect(parsed.leaderboard).toEqual({ rank: 42 });
|
||||
expect(parsed.progression.games.blobbi.xp).toBe(999);
|
||||
expect(parsed.dailyMissions.totalXpEarned).toBe(9999);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Empty / Legacy Content ───────────────────────────────────────────────────
|
||||
|
||||
describe('empty and legacy content handling', () => {
|
||||
it('works on empty string content (legacy profiles)', () => {
|
||||
const { content, globalLevel } = updateProgressionContent('', {
|
||||
games: { blobbi: { level: 1, xp: 0 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(1);
|
||||
expect(globalLevel).toBe(1);
|
||||
expect(parsed.dailyMissions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updateDailyMissionsContent works on empty string', () => {
|
||||
const content = updateDailyMissionsContent('', SAMPLE_DAILY_MISSIONS);
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
expect(parsed.progression).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parseProfileContent works on empty string', () => {
|
||||
const parsed = parseProfileContent('');
|
||||
expect(parsed).toEqual({});
|
||||
});
|
||||
|
||||
it('sequential operations from empty build up correctly', () => {
|
||||
// Start from empty (legacy profile)
|
||||
let content = '';
|
||||
|
||||
// Add progression
|
||||
const { content: c1 } = updateProgressionContent(content, {
|
||||
games: { blobbi: { level: 1, xp: 0 } },
|
||||
});
|
||||
content = c1;
|
||||
|
||||
// Add daily missions
|
||||
content = updateDailyMissionsContent(content, SAMPLE_DAILY_MISSIONS);
|
||||
|
||||
// Add generic section
|
||||
content = updateContentSection(content, 'settings', { theme: 'dark' });
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(1);
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
expect(parsed.settings).toEqual({ theme: 'dark' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
// src/blobbi/core/lib/content-json.test.ts
|
||||
|
||||
/**
|
||||
* Tests for the low-level content JSON utilities.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { safeParseContent, updateContentSection } from './content-json';
|
||||
|
||||
// ─── safeParseContent ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('safeParseContent', () => {
|
||||
it('returns parseOk: true with empty data for empty string', () => {
|
||||
const result = safeParseContent('');
|
||||
expect(result).toEqual({ parseOk: true, data: {} });
|
||||
});
|
||||
|
||||
it('returns parseOk: true with empty data for whitespace', () => {
|
||||
const result = safeParseContent(' \n\t ');
|
||||
expect(result).toEqual({ parseOk: true, data: {} });
|
||||
});
|
||||
|
||||
it('returns parseOk: true for valid JSON object', () => {
|
||||
const result = safeParseContent('{"key": "value", "num": 42}');
|
||||
expect(result.parseOk).toBe(true);
|
||||
expect(result.data).toEqual({ key: 'value', num: 42 });
|
||||
});
|
||||
|
||||
it('preserves all keys including nested objects', () => {
|
||||
const input = JSON.stringify({
|
||||
a: 1,
|
||||
b: { nested: true },
|
||||
c: [1, 2, 3],
|
||||
d: null,
|
||||
});
|
||||
const result = safeParseContent(input);
|
||||
expect(result.parseOk).toBe(true);
|
||||
expect(result.data).toEqual({ a: 1, b: { nested: true }, c: [1, 2, 3], d: null });
|
||||
});
|
||||
|
||||
it('returns parseOk: false for invalid JSON', () => {
|
||||
const result = safeParseContent('not json');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parseOk: false for JSON array', () => {
|
||||
const result = safeParseContent('[1, 2, 3]');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parseOk: false for JSON string', () => {
|
||||
const result = safeParseContent('"hello"');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parseOk: false for JSON number', () => {
|
||||
const result = safeParseContent('42');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parseOk: false for JSON boolean', () => {
|
||||
const result = safeParseContent('true');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parseOk: false for JSON null', () => {
|
||||
const result = safeParseContent('null');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateContentSection ─────────────────────────────────────────────────────
|
||||
|
||||
describe('updateContentSection', () => {
|
||||
it('adds a new section to empty content', () => {
|
||||
const result = updateContentSection('', 'newSection', { value: 42 });
|
||||
expect(JSON.parse(result)).toEqual({ newSection: { value: 42 } });
|
||||
});
|
||||
|
||||
it('adds a new section alongside existing ones', () => {
|
||||
const existing = JSON.stringify({ existing: 'data' });
|
||||
const result = updateContentSection(existing, 'newSection', 'hello');
|
||||
expect(JSON.parse(result)).toEqual({ existing: 'data', newSection: 'hello' });
|
||||
});
|
||||
|
||||
it('overwrites an existing section', () => {
|
||||
const existing = JSON.stringify({ section: 'old', other: 'keep' });
|
||||
const result = updateContentSection(existing, 'section', 'new');
|
||||
expect(JSON.parse(result)).toEqual({ section: 'new', other: 'keep' });
|
||||
});
|
||||
|
||||
it('preserves all sibling keys', () => {
|
||||
const existing = JSON.stringify({ a: 1, b: 2, c: 3, d: 4 });
|
||||
const result = updateContentSection(existing, 'b', 'updated');
|
||||
expect(JSON.parse(result)).toEqual({ a: 1, b: 'updated', c: 3, d: 4 });
|
||||
});
|
||||
|
||||
it('handles invalid JSON input gracefully', () => {
|
||||
const result = updateContentSection('bad json', 'section', 'value');
|
||||
expect(JSON.parse(result)).toEqual({ section: 'value' });
|
||||
});
|
||||
|
||||
it('can set a section to null', () => {
|
||||
const existing = JSON.stringify({ section: 'data' });
|
||||
const result = updateContentSection(existing, 'section', null);
|
||||
expect(JSON.parse(result)).toEqual({ section: null });
|
||||
});
|
||||
|
||||
it('can set a section to an array', () => {
|
||||
const existing = JSON.stringify({ other: 'data' });
|
||||
const result = updateContentSection(existing, 'items', [1, 2, 3]);
|
||||
expect(JSON.parse(result)).toEqual({ other: 'data', items: [1, 2, 3] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// src/blobbi/core/lib/content-json.ts
|
||||
|
||||
/**
|
||||
* Low-level JSON parsing utilities for Kind 11125 content.
|
||||
*
|
||||
* This module provides the shared parsing foundation that both
|
||||
* `blobbonaut-content.ts` and `progression.ts` build on.
|
||||
*
|
||||
* It is intentionally dependency-free (no imports from other blobbi modules)
|
||||
* to prevent circular imports. Higher-level modules import from here;
|
||||
* this module never imports from them.
|
||||
*/
|
||||
|
||||
// ─── Content Parsing Result ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of parsing kind 11125 content JSON.
|
||||
*
|
||||
* Includes a `parseOk` flag so callers can distinguish between:
|
||||
* - Empty/blank content (parseOk: true, data is {})
|
||||
* - Valid JSON (parseOk: true, data is the parsed object)
|
||||
* - Invalid JSON / non-object (parseOk: false, data is {})
|
||||
*
|
||||
* When `parseOk` is false, the content was corrupt. The data field is empty
|
||||
* so callers can still merge their update, but they should be aware that
|
||||
* any data from the corrupt content is unrecoverable.
|
||||
*/
|
||||
export interface ParsedContentResult {
|
||||
/** Whether the content was successfully parsed (or was empty/blank). */
|
||||
parseOk: boolean;
|
||||
/** The parsed data. Empty object when content is blank or unparseable. */
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ─── Safe Content Parsing ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Safely parse kind 11125 content JSON into a plain object.
|
||||
*
|
||||
* Returns `{ parseOk, data }`:
|
||||
* - Empty/blank content → `{ parseOk: true, data: {} }`
|
||||
* - Valid JSON object → `{ parseOk: true, data: <parsed> }`
|
||||
* - Invalid JSON → `{ parseOk: false, data: {} }` + DEV warning
|
||||
* - Non-object JSON → `{ parseOk: false, data: {} }` + DEV warning
|
||||
*
|
||||
* All keys — known and unknown — are preserved in the returned data.
|
||||
*
|
||||
* This function never throws. It is the single entry point for all content
|
||||
* parsing in the kind 11125 system. Both `parseProfileContent` (typed
|
||||
* validation) and the section-update helpers use this under the hood.
|
||||
*
|
||||
* ── Invalid JSON behavior ─────────────────────────────────────────────────
|
||||
*
|
||||
* When content is invalid JSON:
|
||||
* - In development: a warning is logged with the first 200 chars of the
|
||||
* content and the parse error, so developers notice the issue.
|
||||
* - In production: fails silently (no console noise for end users).
|
||||
* - In both environments: returns `{ parseOk: false, data: {} }`.
|
||||
*
|
||||
* The caller can check `parseOk` to decide whether to proceed. All current
|
||||
* callers proceed regardless (merge their update into a fresh object) because
|
||||
* blocking all writes on corrupt content would leave the user stuck with no
|
||||
* recovery path. The corrupt data is lost, but the system stays functional.
|
||||
*/
|
||||
export function safeParseContent(content: string): ParsedContentResult {
|
||||
if (!content || content.trim() === '') {
|
||||
return { parseOk: true, data: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(content);
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
'[content-json] Content JSON parsed but is not a plain object. ' +
|
||||
'Falling back to empty object. Type:',
|
||||
Array.isArray(raw) ? 'array' : typeof raw,
|
||||
);
|
||||
}
|
||||
return { parseOk: false, data: {} };
|
||||
}
|
||||
return { parseOk: true, data: raw as Record<string, unknown> };
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
'[content-json] Failed to parse content JSON. Falling back to empty object.',
|
||||
'Content (first 200 chars):',
|
||||
content.slice(0, 200),
|
||||
'Error:',
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
}
|
||||
return { parseOk: false, data: {} };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Generic Section Update ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update a single top-level section in the kind 11125 content JSON.
|
||||
*
|
||||
* This is the low-level building block for all section-specific helpers.
|
||||
* It guarantees:
|
||||
* 1. The existing content is safely parsed (invalid JSON → {} + warning)
|
||||
* 2. Only the specified `key` is written/overwritten
|
||||
* 3. All sibling sections and unknown keys are preserved
|
||||
* 4. The result is serialized to a valid JSON string
|
||||
*
|
||||
* Prefer the typed helpers (`updateDailyMissionsContent`,
|
||||
* `updateProgressionContent`) over calling this directly. Use this only
|
||||
* for truly generic/dynamic section updates, or when building a new
|
||||
* section-specific helper.
|
||||
*
|
||||
* @param existingContent - The current `event.content` string (may be empty)
|
||||
* @param key - The top-level key to update (e.g. 'dailyMissions')
|
||||
* @param value - The new value for that key
|
||||
* @returns The serialized content string with the section updated
|
||||
*/
|
||||
export function updateContentSection(
|
||||
existingContent: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): string {
|
||||
const { data } = safeParseContent(existingContent);
|
||||
|
||||
const updated = {
|
||||
...data,
|
||||
[key]: value,
|
||||
};
|
||||
|
||||
return JSON.stringify(updated);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
deriveGlobalLevel,
|
||||
parseProgression,
|
||||
mergeProgression,
|
||||
upsertLevelTag,
|
||||
updateProgressionContent,
|
||||
createDefaultProgression,
|
||||
DEFAULT_BLOBBI_GAME_PROGRESSION,
|
||||
DEFAULT_BLOBBI_UNLOCKS,
|
||||
type Progression,
|
||||
type GameProgressionMap,
|
||||
} from './progression';
|
||||
|
||||
// ─── deriveGlobalLevel ────────────────────────────────────────────────────────
|
||||
|
||||
describe('deriveGlobalLevel', () => {
|
||||
it('returns 0 for an empty games map', () => {
|
||||
expect(deriveGlobalLevel({})).toBe(0);
|
||||
});
|
||||
|
||||
it('returns the level of a single game', () => {
|
||||
const games: GameProgressionMap = {
|
||||
blobbi: { level: 5, xp: 100, unlocks: { ...DEFAULT_BLOBBI_UNLOCKS } },
|
||||
};
|
||||
expect(deriveGlobalLevel(games)).toBe(5);
|
||||
});
|
||||
|
||||
it('sums levels from multiple games', () => {
|
||||
const games: GameProgressionMap = {
|
||||
blobbi: { level: 3, xp: 50, unlocks: { ...DEFAULT_BLOBBI_UNLOCKS } },
|
||||
farm: { level: 7, xp: 200 },
|
||||
racing: { level: 2, xp: 10 },
|
||||
};
|
||||
expect(deriveGlobalLevel(games)).toBe(12);
|
||||
});
|
||||
|
||||
it('skips undefined or zero-level entries', () => {
|
||||
const games: GameProgressionMap = {
|
||||
blobbi: { level: 4, xp: 0, unlocks: { ...DEFAULT_BLOBBI_UNLOCKS } },
|
||||
farm: undefined,
|
||||
racing: { level: 0, xp: 0 },
|
||||
};
|
||||
expect(deriveGlobalLevel(games)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseProgression ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseProgression', () => {
|
||||
it('returns undefined for non-objects', () => {
|
||||
expect(parseProgression(null)).toBeUndefined();
|
||||
expect(parseProgression(42)).toBeUndefined();
|
||||
expect(parseProgression('string')).toBeUndefined();
|
||||
expect(parseProgression([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when games is missing', () => {
|
||||
expect(parseProgression({ global: { level: 1, xp: 0 } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when games is not an object', () => {
|
||||
expect(parseProgression({ global: { level: 1, xp: 0 }, games: 'bad' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses a valid Blobbi progression', () => {
|
||||
const raw = {
|
||||
global: { level: 99, xp: 500 }, // level should be re-derived, not trusted
|
||||
games: {
|
||||
blobbi: {
|
||||
level: 3,
|
||||
xp: 150,
|
||||
unlocks: { maxBlobbis: 2, realInventoryEnabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = parseProgression(raw);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.global.level).toBe(3); // re-derived, not 99
|
||||
expect(result!.global.xp).toBe(500); // preserved as-is
|
||||
expect(result!.games.blobbi).toEqual({
|
||||
level: 3,
|
||||
xp: 150,
|
||||
unlocks: { maxBlobbis: 2, realInventoryEnabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults Blobbi unlocks for malformed unlock data', () => {
|
||||
const raw = {
|
||||
global: { level: 1, xp: 0 },
|
||||
games: {
|
||||
blobbi: { level: 1, xp: 0, unlocks: 'not-an-object' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = parseProgression(raw);
|
||||
expect(result!.games.blobbi!.unlocks).toEqual(DEFAULT_BLOBBI_UNLOCKS);
|
||||
});
|
||||
|
||||
it('preserves unknown game entries', () => {
|
||||
const raw = {
|
||||
global: { level: 0, xp: 0 },
|
||||
games: {
|
||||
blobbi: { level: 2, xp: 50, unlocks: { maxBlobbis: 1, realInventoryEnabled: false } },
|
||||
racing: { level: 5, xp: 300, unlocks: { turboEnabled: true } },
|
||||
},
|
||||
};
|
||||
|
||||
const result = parseProgression(raw);
|
||||
expect(result!.games.racing).toEqual({
|
||||
level: 5,
|
||||
xp: 300,
|
||||
unlocks: { turboEnabled: true },
|
||||
});
|
||||
expect(result!.global.level).toBe(7); // 2 + 5
|
||||
});
|
||||
|
||||
it('skips malformed game entries', () => {
|
||||
const raw = {
|
||||
global: { level: 0, xp: 0 },
|
||||
games: {
|
||||
blobbi: { level: 1, xp: 0, unlocks: { maxBlobbis: 1, realInventoryEnabled: false } },
|
||||
bad: 'not-an-object',
|
||||
alsobad: null,
|
||||
},
|
||||
};
|
||||
|
||||
const result = parseProgression(raw);
|
||||
expect(Object.keys(result!.games)).toEqual(['blobbi']);
|
||||
});
|
||||
|
||||
it('defaults missing numeric fields to 0', () => {
|
||||
const raw = {
|
||||
global: {},
|
||||
games: {
|
||||
blobbi: { unlocks: {} },
|
||||
},
|
||||
};
|
||||
|
||||
const result = parseProgression(raw);
|
||||
expect(result!.games.blobbi!.level).toBe(0);
|
||||
expect(result!.games.blobbi!.xp).toBe(0);
|
||||
expect(result!.global.xp).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mergeProgression ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('mergeProgression', () => {
|
||||
const baseProgression: Progression = {
|
||||
global: { level: 3, xp: 100 },
|
||||
games: {
|
||||
blobbi: { level: 3, xp: 100, unlocks: { maxBlobbis: 1, realInventoryEnabled: false } },
|
||||
},
|
||||
};
|
||||
|
||||
it('initializes from undefined with Blobbi defaults when updating blobbi', () => {
|
||||
const result = mergeProgression(undefined, {
|
||||
games: { blobbi: { xp: 50 } },
|
||||
});
|
||||
|
||||
expect(result.games.blobbi).toEqual({
|
||||
level: 1, // default
|
||||
xp: 50, // from update
|
||||
unlocks: DEFAULT_BLOBBI_UNLOCKS,
|
||||
});
|
||||
expect(result.global.level).toBe(1);
|
||||
});
|
||||
|
||||
it('updates only the specified game field', () => {
|
||||
const result = mergeProgression(baseProgression, {
|
||||
games: { blobbi: { xp: 200 } },
|
||||
});
|
||||
|
||||
expect(result.games.blobbi!.level).toBe(3); // preserved
|
||||
expect(result.games.blobbi!.xp).toBe(200); // updated
|
||||
expect(result.games.blobbi!.unlocks).toEqual({ maxBlobbis: 1, realInventoryEnabled: false }); // preserved
|
||||
});
|
||||
|
||||
it('merges unlocks without dropping existing fields', () => {
|
||||
const result = mergeProgression(baseProgression, {
|
||||
games: { blobbi: { unlocks: { maxBlobbis: 3 } } },
|
||||
});
|
||||
|
||||
expect(result.games.blobbi!.unlocks).toEqual({
|
||||
maxBlobbis: 3, // updated
|
||||
realInventoryEnabled: false, // preserved
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves other games when updating one game', () => {
|
||||
const withMultiple: Progression = {
|
||||
global: { level: 8, xp: 0 },
|
||||
games: {
|
||||
blobbi: { level: 3, xp: 100, unlocks: { maxBlobbis: 1, realInventoryEnabled: false } },
|
||||
farm: { level: 5, xp: 300 },
|
||||
},
|
||||
};
|
||||
|
||||
const result = mergeProgression(withMultiple, {
|
||||
games: { blobbi: { level: 4 } },
|
||||
});
|
||||
|
||||
expect(result.games.farm).toEqual({ level: 5, xp: 300 }); // untouched
|
||||
expect(result.games.blobbi!.level).toBe(4);
|
||||
expect(result.global.level).toBe(9); // 4 + 5
|
||||
});
|
||||
|
||||
it('always re-derives global level, ignoring caller-provided value', () => {
|
||||
const result = mergeProgression(baseProgression, {
|
||||
global: { level: 999 }, // should be ignored
|
||||
games: { blobbi: { level: 7 } },
|
||||
});
|
||||
|
||||
expect(result.global.level).toBe(7); // derived, not 999
|
||||
});
|
||||
|
||||
it('preserves global.xp from existing when not in update', () => {
|
||||
const result = mergeProgression(baseProgression, {
|
||||
games: { blobbi: { level: 4 } },
|
||||
});
|
||||
|
||||
expect(result.global.xp).toBe(100); // from base
|
||||
});
|
||||
|
||||
it('updates global.xp when provided', () => {
|
||||
const result = mergeProgression(baseProgression, {
|
||||
global: { xp: 500 },
|
||||
});
|
||||
|
||||
expect(result.global.xp).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── upsertLevelTag ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('upsertLevelTag', () => {
|
||||
it('appends level tag when none exists', () => {
|
||||
const tags = [['d', 'abc'], ['name', 'test']];
|
||||
const result = upsertLevelTag(tags, 5);
|
||||
|
||||
expect(result).toEqual([['d', 'abc'], ['name', 'test'], ['level', '5']]);
|
||||
});
|
||||
|
||||
it('updates existing level tag in place', () => {
|
||||
const tags = [['d', 'abc'], ['level', '3'], ['name', 'test']];
|
||||
const result = upsertLevelTag(tags, 7);
|
||||
|
||||
expect(result).toEqual([['d', 'abc'], ['level', '7'], ['name', 'test']]);
|
||||
});
|
||||
|
||||
it('does not mutate the original array', () => {
|
||||
const tags = [['d', 'abc'], ['level', '3']];
|
||||
const original = JSON.parse(JSON.stringify(tags));
|
||||
upsertLevelTag(tags, 10);
|
||||
|
||||
expect(tags).toEqual(original);
|
||||
});
|
||||
|
||||
it('handles level 0', () => {
|
||||
const tags = [['d', 'abc']];
|
||||
const result = upsertLevelTag(tags, 0);
|
||||
|
||||
expect(result).toEqual([['d', 'abc'], ['level', '0']]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateProgressionContent ─────────────────────────────────────────────────
|
||||
|
||||
describe('updateProgressionContent', () => {
|
||||
it('initializes progression in empty content', () => {
|
||||
const { content, globalLevel } = updateProgressionContent('', {
|
||||
games: { blobbi: { level: 1, xp: 0 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression).toBeDefined();
|
||||
expect(parsed.progression.games.blobbi.level).toBe(1);
|
||||
expect(globalLevel).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves existing dailyMissions when updating progression', () => {
|
||||
const existing = JSON.stringify({
|
||||
dailyMissions: { date: '2026-04-06', missions: [], bonusClaimed: false, rerollsRemaining: 3, totalXpEarned: 50, lastUpdatedAt: 1000 },
|
||||
});
|
||||
|
||||
const { content } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { level: 2 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.dailyMissions).toBeDefined();
|
||||
expect(parsed.dailyMissions.date).toBe('2026-04-06');
|
||||
expect(parsed.dailyMissions.totalXpEarned).toBe(50);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves unknown top-level keys', () => {
|
||||
const existing = JSON.stringify({
|
||||
dailyMissions: { date: '2026-04-06', missions: [], bonusClaimed: false, rerollsRemaining: 3, totalXpEarned: 0, lastUpdatedAt: 0 },
|
||||
futureFeature: { some: 'data' },
|
||||
});
|
||||
|
||||
const { content } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { xp: 100 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.futureFeature).toEqual({ some: 'data' });
|
||||
});
|
||||
|
||||
it('handles corrupt content gracefully', () => {
|
||||
const { content, globalLevel } = updateProgressionContent('not valid json!!!', {
|
||||
games: { blobbi: { level: 1, xp: 0 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(1);
|
||||
expect(globalLevel).toBe(1);
|
||||
// dailyMissions should NOT be present (corrupt content had none)
|
||||
expect(parsed.dailyMissions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('re-derives global level correctly in content', () => {
|
||||
const existing = JSON.stringify({
|
||||
progression: {
|
||||
global: { level: 5, xp: 0 },
|
||||
games: {
|
||||
blobbi: { level: 3, xp: 100, unlocks: { maxBlobbis: 1, realInventoryEnabled: false } },
|
||||
farm: { level: 2, xp: 50 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { content, globalLevel } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { level: 4 } },
|
||||
});
|
||||
|
||||
expect(globalLevel).toBe(6); // 4 + 2
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.global.level).toBe(6);
|
||||
expect(parsed.progression.games.farm.level).toBe(2); // untouched
|
||||
});
|
||||
});
|
||||
|
||||
// ─── createDefaultProgression ─────────────────────────────────────────────────
|
||||
|
||||
describe('createDefaultProgression', () => {
|
||||
it('returns a valid default progression', () => {
|
||||
const def = createDefaultProgression();
|
||||
|
||||
expect(def.global.level).toBe(1);
|
||||
expect(def.global.xp).toBe(0);
|
||||
expect(def.games.blobbi).toBeDefined();
|
||||
expect(def.games.blobbi!.level).toBe(1);
|
||||
expect(def.games.blobbi!.xp).toBe(0);
|
||||
expect(def.games.blobbi!.unlocks).toEqual(DEFAULT_BLOBBI_UNLOCKS);
|
||||
});
|
||||
|
||||
it('returns independent copies (no shared references)', () => {
|
||||
const a = createDefaultProgression();
|
||||
const b = createDefaultProgression();
|
||||
|
||||
a.games.blobbi!.level = 99;
|
||||
expect(b.games.blobbi!.level).toBe(1);
|
||||
|
||||
a.games.blobbi!.unlocks.maxBlobbis = 99;
|
||||
expect(b.games.blobbi!.unlocks.maxBlobbis).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DEFAULT_BLOBBI_GAME_PROGRESSION ──────────────────────────────────────────
|
||||
|
||||
describe('DEFAULT_BLOBBI_GAME_PROGRESSION', () => {
|
||||
it('has the expected shape', () => {
|
||||
expect(DEFAULT_BLOBBI_GAME_PROGRESSION).toEqual({
|
||||
level: 1,
|
||||
xp: 0,
|
||||
unlocks: { maxBlobbis: 1, realInventoryEnabled: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,533 @@
|
||||
// src/blobbi/core/lib/progression.ts
|
||||
|
||||
/**
|
||||
* Blobbonaut Progression System — Types, defaults, derivation, and merge helpers.
|
||||
*
|
||||
* This module defines the progression structure that lives inside the kind 11125
|
||||
* event content JSON alongside `dailyMissions` and any future top-level keys.
|
||||
*
|
||||
* ── Design Rationale ──────────────────────────────────────────────────────────
|
||||
*
|
||||
* Why `progression.games` is the source of truth:
|
||||
* Each game (blobbi, farm, racing, …) independently tracks its own level and
|
||||
* XP. This makes it straightforward to add new games without affecting
|
||||
* existing ones. The per-game data is authoritative; the global summary is
|
||||
* always derived from it.
|
||||
*
|
||||
* Why `progression.global.level` is derived, not primary:
|
||||
* A single authoritative global level would need to be kept in sync with every
|
||||
* game mutation — an error-prone process that silently corrupts data if any
|
||||
* write path forgets to update both. Instead, we derive the global level as
|
||||
* the sum of all game levels immediately before serialization, making it
|
||||
* impossible to drift out of sync.
|
||||
*
|
||||
* Why `progression.global.xp` exists but has no gameplay rules yet:
|
||||
* We reserve the field in the schema so future phases can introduce global XP
|
||||
* accumulation without a schema migration. For now it is always written as-is
|
||||
* and never used for derivation or gating.
|
||||
*
|
||||
* Why the merge logic must be conservative:
|
||||
* Multiple write paths update kind 11125 content (daily missions, shop
|
||||
* purchases via tags, profile normalization, etc.). Each write path must:
|
||||
* 1. Parse existing content (never assume shape)
|
||||
* 2. Touch only its own section
|
||||
* 3. Preserve every unknown key at every level
|
||||
* A shallow spread at the top level is not enough — the `progression` object
|
||||
* itself contains nested structures (`games`, each game's `unlocks`) that must
|
||||
* be merged recursively without dropping siblings.
|
||||
*
|
||||
* ── Standard Write Path ───────────────────────────────────────────────────────
|
||||
*
|
||||
* Every kind 11125 content write that touches `progression` should flow
|
||||
* through `updateProgressionContent()`. This guarantees:
|
||||
* - Unknown top-level keys (dailyMissions, future sections) are never dropped
|
||||
* - `global.level` is always consistent with game data
|
||||
* - The `["level", "<n>"]` tag can be updated from the returned `globalLevel`
|
||||
* - Only the `progression` section is modified; everything else is preserved
|
||||
*
|
||||
* The `["level", "<n>"]` tag is a queryable mirror only — it exists so relays
|
||||
* can filter profiles by level without parsing content JSON. It must never be
|
||||
* treated as a source of truth.
|
||||
*/
|
||||
|
||||
import { safeParseContent } from './content-json';
|
||||
|
||||
// ─── Game Identifiers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Known game identifiers within the progression system.
|
||||
* New games are added here as string literals for type safety.
|
||||
* The structure also accepts unknown game keys for forward compatibility.
|
||||
*/
|
||||
export type KnownGameId = 'blobbi';
|
||||
|
||||
// ─── Unlock Shapes ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Unlock flags for the Blobbi game specifically.
|
||||
* Each flag controls a capability that becomes available at certain levels.
|
||||
*/
|
||||
export interface BlobbiUnlocks {
|
||||
/** Maximum number of Blobbis the player may own simultaneously. */
|
||||
maxBlobbis: number;
|
||||
/** Whether the real (non-preview) inventory system is enabled. */
|
||||
realInventoryEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default unlocks for a brand-new Blobbi game progression.
|
||||
* Level 1 players start with 1 Blobbi slot and no real inventory.
|
||||
*/
|
||||
export const DEFAULT_BLOBBI_UNLOCKS: Readonly<BlobbiUnlocks> = {
|
||||
maxBlobbis: 1,
|
||||
realInventoryEnabled: false,
|
||||
} as const;
|
||||
|
||||
// ─── Per-Game Progression ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Base shape shared by every game's progression entry.
|
||||
* Individual games extend this with their own `unlocks` type.
|
||||
*/
|
||||
export interface BaseGameProgression {
|
||||
/** The game's current level (starts at 1 for initialized games). */
|
||||
level: number;
|
||||
/** The game's current XP towards the next level. */
|
||||
xp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blobbi game progression entry.
|
||||
*/
|
||||
export interface BlobbiGameProgression extends BaseGameProgression {
|
||||
unlocks: BlobbiUnlocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `progression.games` map.
|
||||
*
|
||||
* Known games get explicit types for editor support and validation.
|
||||
* Unknown game keys are accepted as `BaseGameProgression & { unlocks?: unknown }`
|
||||
* for forward compatibility — a newer client version may write game entries we
|
||||
* don't recognize yet.
|
||||
*/
|
||||
export interface GameProgressionMap {
|
||||
blobbi?: BlobbiGameProgression;
|
||||
/** Forward-compatible catch-all for future games. */
|
||||
[gameId: string]: (BaseGameProgression & { unlocks?: unknown }) | undefined;
|
||||
}
|
||||
|
||||
// ─── Global Progression ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The derived global summary.
|
||||
*
|
||||
* `level` is always the sum of all `games.*.level`. It is recalculated
|
||||
* before every write and should never be manually set by callers.
|
||||
*
|
||||
* `xp` is reserved for future use. It is preserved as-is during
|
||||
* read-modify-write but no gameplay rules depend on it yet.
|
||||
*/
|
||||
export interface GlobalProgression {
|
||||
level: number;
|
||||
xp: number;
|
||||
}
|
||||
|
||||
// ─── Top-Level Progression ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The full `progression` section of the kind 11125 content JSON.
|
||||
*/
|
||||
export interface Progression {
|
||||
global: GlobalProgression;
|
||||
games: GameProgressionMap;
|
||||
}
|
||||
|
||||
// ─── Defaults ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Default progression for a brand-new Blobbi game entry.
|
||||
* This is the starting state when a player first enters the Blobbi game.
|
||||
*/
|
||||
export const DEFAULT_BLOBBI_GAME_PROGRESSION: Readonly<BlobbiGameProgression> = {
|
||||
level: 1,
|
||||
xp: 0,
|
||||
unlocks: { ...DEFAULT_BLOBBI_UNLOCKS },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Build a fresh progression structure with only the Blobbi game initialized.
|
||||
* The global level is derived (sum of game levels = 1).
|
||||
*/
|
||||
export function createDefaultProgression(): Progression {
|
||||
return {
|
||||
global: { level: 1, xp: 0 },
|
||||
games: {
|
||||
blobbi: { ...DEFAULT_BLOBBI_GAME_PROGRESSION, unlocks: { ...DEFAULT_BLOBBI_UNLOCKS } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Derivation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive the global level from the sum of all per-game levels.
|
||||
*
|
||||
* This is the **only** correct way to determine the global level.
|
||||
* Never read `progression.global.level` as authoritative — always re-derive
|
||||
* before comparing or persisting.
|
||||
*
|
||||
* Games that are `undefined` or missing a numeric `level` are skipped.
|
||||
*/
|
||||
export function deriveGlobalLevel(games: GameProgressionMap): number {
|
||||
let total = 0;
|
||||
for (const gameId of Object.keys(games)) {
|
||||
const game = games[gameId];
|
||||
if (game && typeof game.level === 'number' && game.level > 0) {
|
||||
total += game.level;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ─── Parsing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate and normalize a raw `progression` value from parsed JSON.
|
||||
*
|
||||
* - Returns `undefined` if the value is not a usable object (caller decides
|
||||
* whether to initialize defaults or leave absent).
|
||||
* - Preserves unknown game keys and unknown fields within game entries.
|
||||
* - Validates the Blobbi game entry with type-specific checks.
|
||||
* - Re-derives `global.level` from game data to ensure consistency.
|
||||
*
|
||||
* This function never throws. Malformed sub-trees are silently dropped or
|
||||
* defaulted so that a corrupt `progression` field cannot crash the app.
|
||||
*/
|
||||
export function parseProgression(raw: unknown): Progression | undefined {
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
// ── Parse games ──
|
||||
|
||||
const rawGames = obj.games;
|
||||
if (typeof rawGames !== 'object' || rawGames === null || Array.isArray(rawGames)) {
|
||||
// No usable games map — cannot construct a valid progression.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const gamesObj = rawGames as Record<string, unknown>;
|
||||
const games: GameProgressionMap = {};
|
||||
|
||||
for (const gameId of Object.keys(gamesObj)) {
|
||||
const rawGame = gamesObj[gameId];
|
||||
if (typeof rawGame !== 'object' || rawGame === null || Array.isArray(rawGame)) {
|
||||
continue; // Skip malformed game entries
|
||||
}
|
||||
|
||||
const gameEntry = rawGame as Record<string, unknown>;
|
||||
const level = typeof gameEntry.level === 'number' ? gameEntry.level : 0;
|
||||
const xp = typeof gameEntry.xp === 'number' ? gameEntry.xp : 0;
|
||||
|
||||
if (gameId === 'blobbi') {
|
||||
// Type-specific parsing for Blobbi
|
||||
games.blobbi = {
|
||||
level,
|
||||
xp,
|
||||
unlocks: parseBlobbiUnlocks(gameEntry.unlocks),
|
||||
};
|
||||
} else {
|
||||
// Forward-compatible: preserve unknown games with their base fields + unlocks
|
||||
const entry: BaseGameProgression & { unlocks?: unknown } = { level, xp };
|
||||
if (gameEntry.unlocks !== undefined) {
|
||||
entry.unlocks = gameEntry.unlocks;
|
||||
}
|
||||
games[gameId] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parse global (re-derive level for consistency) ──
|
||||
|
||||
const rawGlobal = obj.global;
|
||||
const globalXp =
|
||||
typeof rawGlobal === 'object' && rawGlobal !== null && !Array.isArray(rawGlobal)
|
||||
? typeof (rawGlobal as Record<string, unknown>).xp === 'number'
|
||||
? (rawGlobal as Record<string, unknown>).xp as number
|
||||
: 0
|
||||
: 0;
|
||||
|
||||
return {
|
||||
global: {
|
||||
level: deriveGlobalLevel(games),
|
||||
xp: globalXp,
|
||||
},
|
||||
games,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate Blobbi-specific unlocks from raw JSON.
|
||||
* Falls back to defaults for any missing or malformed fields.
|
||||
*/
|
||||
function parseBlobbiUnlocks(raw: unknown): BlobbiUnlocks {
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||
return { ...DEFAULT_BLOBBI_UNLOCKS };
|
||||
}
|
||||
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
maxBlobbis:
|
||||
typeof obj.maxBlobbis === 'number' && obj.maxBlobbis >= 1
|
||||
? obj.maxBlobbis
|
||||
: DEFAULT_BLOBBI_UNLOCKS.maxBlobbis,
|
||||
realInventoryEnabled:
|
||||
typeof obj.realInventoryEnabled === 'boolean'
|
||||
? obj.realInventoryEnabled
|
||||
: DEFAULT_BLOBBI_UNLOCKS.realInventoryEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Merge Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deep-merge an update into an existing Progression structure.
|
||||
*
|
||||
* Merge rules (conservative, section-scoped):
|
||||
* 1. `games` entries are merged per-key: only the specified game is touched.
|
||||
* 2. Unmentioned games are preserved exactly as-is.
|
||||
* 3. Within a game entry, each field is individually merged — unknown fields
|
||||
* within the game entry are preserved.
|
||||
* 4. `unlocks` within a game entry are shallow-merged (known fields override,
|
||||
* unknown fields preserved).
|
||||
* 5. `global.level` is always re-derived after merging — callers never set it.
|
||||
* 6. `global.xp` is preserved from existing unless explicitly provided.
|
||||
*
|
||||
* @param existing - The current progression (may be `undefined` for first-time init)
|
||||
* @param update - A partial progression update. Only specified paths are written.
|
||||
* @returns - The merged Progression with re-derived global level.
|
||||
*/
|
||||
export function mergeProgression(
|
||||
existing: Progression | undefined,
|
||||
update: DeepPartialProgression,
|
||||
): Progression {
|
||||
// Start from existing or create a minimal scaffold
|
||||
const base: Progression = existing ?? { global: { level: 0, xp: 0 }, games: {} };
|
||||
|
||||
// ── Merge games ──
|
||||
|
||||
const mergedGames: GameProgressionMap = { ...base.games };
|
||||
|
||||
if (update.games) {
|
||||
for (const gameId of Object.keys(update.games)) {
|
||||
const existingGame = mergedGames[gameId];
|
||||
const updateGame = (update.games as Record<string, unknown>)[gameId];
|
||||
|
||||
if (typeof updateGame !== 'object' || updateGame === null) {
|
||||
continue; // Skip invalid updates
|
||||
}
|
||||
|
||||
const updateObj = updateGame as Record<string, unknown>;
|
||||
|
||||
if (gameId === 'blobbi') {
|
||||
// Type-safe merge for Blobbi
|
||||
const existingBlobbi = (existingGame as BlobbiGameProgression | undefined);
|
||||
mergedGames.blobbi = mergeBlobbiGame(existingBlobbi, updateObj);
|
||||
} else {
|
||||
// Generic merge for unknown games
|
||||
const existingEntry = existingGame as (BaseGameProgression & { unlocks?: unknown }) | undefined;
|
||||
mergedGames[gameId] = mergeGenericGame(existingEntry, updateObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Re-derive global ──
|
||||
//
|
||||
// `global.level` is ALWAYS the sum of game levels. This is non-negotiable —
|
||||
// even if the caller provides `update.global.level`, we ignore it.
|
||||
// `global.xp` is preserved from existing unless the update explicitly provides it.
|
||||
|
||||
const mergedGlobalXp =
|
||||
update.global && typeof update.global.xp === 'number'
|
||||
? update.global.xp
|
||||
: base.global.xp;
|
||||
|
||||
return {
|
||||
global: {
|
||||
level: deriveGlobalLevel(mergedGames),
|
||||
xp: mergedGlobalXp,
|
||||
},
|
||||
games: mergedGames,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an update into an existing Blobbi game progression entry.
|
||||
* Preserves existing fields not mentioned in the update.
|
||||
*/
|
||||
function mergeBlobbiGame(
|
||||
existing: BlobbiGameProgression | undefined,
|
||||
update: Record<string, unknown>,
|
||||
): BlobbiGameProgression {
|
||||
const base = existing ?? {
|
||||
...DEFAULT_BLOBBI_GAME_PROGRESSION,
|
||||
unlocks: { ...DEFAULT_BLOBBI_UNLOCKS },
|
||||
};
|
||||
|
||||
const merged: BlobbiGameProgression = {
|
||||
level: typeof update.level === 'number' ? update.level : base.level,
|
||||
xp: typeof update.xp === 'number' ? update.xp : base.xp,
|
||||
unlocks: base.unlocks,
|
||||
};
|
||||
|
||||
// Merge unlocks if provided
|
||||
if (typeof update.unlocks === 'object' && update.unlocks !== null && !Array.isArray(update.unlocks)) {
|
||||
const unlockUpdate = update.unlocks as Record<string, unknown>;
|
||||
merged.unlocks = {
|
||||
maxBlobbis:
|
||||
typeof unlockUpdate.maxBlobbis === 'number'
|
||||
? unlockUpdate.maxBlobbis
|
||||
: base.unlocks.maxBlobbis,
|
||||
realInventoryEnabled:
|
||||
typeof unlockUpdate.realInventoryEnabled === 'boolean'
|
||||
? unlockUpdate.realInventoryEnabled
|
||||
: base.unlocks.realInventoryEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an update into an existing generic (unknown) game progression entry.
|
||||
*/
|
||||
function mergeGenericGame(
|
||||
existing: (BaseGameProgression & { unlocks?: unknown }) | undefined,
|
||||
update: Record<string, unknown>,
|
||||
): BaseGameProgression & { unlocks?: unknown } {
|
||||
const base = existing ?? { level: 0, xp: 0 };
|
||||
|
||||
const merged: BaseGameProgression & { unlocks?: unknown } = {
|
||||
level: typeof update.level === 'number' ? update.level : base.level,
|
||||
xp: typeof update.xp === 'number' ? update.xp : base.xp,
|
||||
};
|
||||
|
||||
// Preserve or update unlocks (opaque for unknown games)
|
||||
if (update.unlocks !== undefined) {
|
||||
merged.unlocks = update.unlocks;
|
||||
} else if (base.unlocks !== undefined) {
|
||||
merged.unlocks = base.unlocks;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ─── Tag Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upsert the `["level", "<value>"]` tag in a tag array.
|
||||
*
|
||||
* - If a `level` tag already exists, its value is updated in place.
|
||||
* - If no `level` tag exists, one is appended.
|
||||
* - All other tags are preserved exactly as-is (order, values, extra elements).
|
||||
*
|
||||
* This mirrors the derived `progression.global.level` into a queryable tag
|
||||
* so relays can filter profiles by level without parsing content JSON.
|
||||
*
|
||||
* @param tags - The current tag array (will not be mutated)
|
||||
* @param level - The derived global level to write
|
||||
* @returns - A new tag array with the level tag upserted
|
||||
*/
|
||||
export function upsertLevelTag(tags: string[][], level: number): string[][] {
|
||||
const levelStr = String(level);
|
||||
let found = false;
|
||||
|
||||
const result = tags.map((tag) => {
|
||||
if (tag[0] === 'level') {
|
||||
found = true;
|
||||
return ['level', levelStr];
|
||||
}
|
||||
return tag;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
result.push(['level', levelStr]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Centralized Content Update ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update the `progression` section inside a kind 11125 content string.
|
||||
*
|
||||
* This is the **standard entry point** for any code path that needs to modify
|
||||
* Blobbi game progression (or any future game). It:
|
||||
*
|
||||
* 1. Parses the existing content safely (empty/invalid → empty object)
|
||||
* 2. Extracts the existing `progression` (may be `undefined`)
|
||||
* 3. Merges the update conservatively (only touches specified paths)
|
||||
* 4. Re-derives `global.level`
|
||||
* 5. Writes the merged `progression` back, preserving all sibling keys
|
||||
* (`dailyMissions`, any future keys, and any unknown keys)
|
||||
* 6. Returns both the serialized content string and the derived global level
|
||||
* so the caller can also upsert the `level` tag.
|
||||
*
|
||||
* ── Why this function should be the standard path ──
|
||||
*
|
||||
* Every future kind 11125 content write that touches `progression` should flow
|
||||
* through `updateProgressionContent` (or through a higher-level helper that
|
||||
* calls it). This guarantees:
|
||||
* - Unknown top-level keys are never dropped
|
||||
* - `dailyMissions` is never overwritten
|
||||
* - `global.level` is always consistent with game data
|
||||
* - The `level` tag can always be updated from the returned value
|
||||
*
|
||||
* @param existingContent - The current `event.content` string (may be empty)
|
||||
* @param progressionUpdate - A partial progression update
|
||||
* @returns `{ content, globalLevel }` — serialized content + derived level for the tag
|
||||
*/
|
||||
export function updateProgressionContent(
|
||||
existingContent: string,
|
||||
progressionUpdate: DeepPartialProgression,
|
||||
): { content: string; globalLevel: number } {
|
||||
// Step 1: Parse the full content safely. Unknown keys are preserved.
|
||||
const { data } = safeParseContent(existingContent);
|
||||
|
||||
// Step 2: Extract and merge progression
|
||||
const existingProgression = parseProgression(data.progression);
|
||||
const merged = mergeProgression(existingProgression, progressionUpdate);
|
||||
|
||||
// Step 3: Write merged progression back, preserving all other keys
|
||||
const updated = {
|
||||
...data,
|
||||
progression: merged,
|
||||
};
|
||||
|
||||
return {
|
||||
content: JSON.stringify(updated),
|
||||
globalLevel: merged.global.level,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Deep Partial Type ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A deep-partial type for progression updates.
|
||||
*
|
||||
* Callers provide only the paths they want to change. Unmentioned fields
|
||||
* at every nesting level are preserved from the existing state.
|
||||
*/
|
||||
export interface DeepPartialProgression {
|
||||
global?: Partial<GlobalProgression>;
|
||||
games?: {
|
||||
[gameId: string]: Partial<BaseGameProgression & { unlocks?: unknown }> | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE: safeParseContent is imported from blobbonaut-content.ts (the shared
|
||||
// content parsing entry point for all kind 11125 content operations).
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* ProgressionDevPanel - DEV MODE ONLY
|
||||
*
|
||||
* Simple testing panel for manually triggering kind 11125 progression writes.
|
||||
* All actions flow through the proper centralized helpers:
|
||||
* - updateProgressionContent() for content JSON
|
||||
* - upsertLevelTag() for the queryable level tag
|
||||
* - fetchFreshEvent() + prev for safe read-modify-write
|
||||
*
|
||||
* This component is temporary and can be removed when progression is
|
||||
* integrated into real gameplay.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
import { KIND_BLOBBONAUT_PROFILE } from '@/blobbi/core/lib/blobbi';
|
||||
import { parseProfileContent } from '@/blobbi/core/lib/blobbonaut-content';
|
||||
import { updateProgressionContent, upsertLevelTag } from '@/blobbi/core/lib/progression';
|
||||
import { isLocalhostDev } from './index';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ProgressionDevPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after a successful write to update the cached profile event */
|
||||
onProfileUpdated?: (event: import('@nostrify/nostrify').NostrEvent) => void;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProgressionDevPanel({ isOpen, onClose, onProfileUpdated }: ProgressionDevPanelProps) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [lastResult, setLastResult] = useState<string | null>(null);
|
||||
|
||||
// Guard: only render in localhost dev mode
|
||||
if (!isLocalhostDev()) return null;
|
||||
|
||||
/**
|
||||
* Core write helper: fetch fresh event, apply progression update,
|
||||
* upsert level tag, publish. This is the pattern all future progression
|
||||
* writes should follow.
|
||||
*/
|
||||
async function applyProgressionUpdate(
|
||||
label: string,
|
||||
getUpdate: (currentContent: string) => Parameters<typeof updateProgressionContent>[1],
|
||||
) {
|
||||
if (!user?.pubkey) {
|
||||
toast({ title: 'Not logged in', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setLastResult(null);
|
||||
|
||||
try {
|
||||
// 1. Fetch fresh event (safe read-modify-write)
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [KIND_BLOBBONAUT_PROFILE],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
|
||||
const existingContent = prev?.content ?? '';
|
||||
const existingTags = prev?.tags ?? [];
|
||||
|
||||
// 2. Apply progression update through centralized helper
|
||||
const progressionUpdate = getUpdate(existingContent);
|
||||
const { content: updatedContent, globalLevel } = updateProgressionContent(
|
||||
existingContent,
|
||||
progressionUpdate,
|
||||
);
|
||||
|
||||
// 3. Upsert level tag (queryable mirror)
|
||||
const updatedTags = upsertLevelTag(existingTags, globalLevel);
|
||||
|
||||
// 4. Publish
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: updatedContent,
|
||||
tags: updatedTags,
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
onProfileUpdated?.(event);
|
||||
|
||||
// Show result
|
||||
const parsed = parseProfileContent(updatedContent);
|
||||
const blobbi = parsed.progression?.games?.blobbi;
|
||||
setLastResult(
|
||||
`${label}: level=${blobbi?.level ?? '?'}, xp=${blobbi?.xp ?? '?'}, global=${globalLevel}`,
|
||||
);
|
||||
|
||||
toast({ title: 'DEV: Progression updated', description: label });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setLastResult(`Error: ${msg}`);
|
||||
toast({ title: 'DEV: Write failed', description: msg, variant: 'destructive' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
|
||||
const addXp = (amount: number) =>
|
||||
applyProgressionUpdate(`+${amount} Blobbi XP`, (content) => {
|
||||
const parsed = parseProfileContent(content);
|
||||
const currentXp = parsed.progression?.games?.blobbi?.xp ?? 0;
|
||||
return { games: { blobbi: { xp: currentXp + amount } } };
|
||||
});
|
||||
|
||||
const addLevel = () =>
|
||||
applyProgressionUpdate('+1 Blobbi Level', (content) => {
|
||||
const parsed = parseProfileContent(content);
|
||||
const currentLevel = parsed.progression?.games?.blobbi?.level ?? 1;
|
||||
return { games: { blobbi: { level: currentLevel + 1 } } };
|
||||
});
|
||||
|
||||
const resetProgression = () =>
|
||||
applyProgressionUpdate('Reset Blobbi Progression', () => ({
|
||||
games: { blobbi: { level: 1, xp: 0, unlocks: { maxBlobbis: 1, realInventoryEnabled: false } } },
|
||||
}));
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
Progression Dev Panel
|
||||
<Badge variant="outline" className="text-xs">DEV</Badge>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Test kind 11125 progression writes. All actions use the proper helpers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* XP buttons */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Blobbi XP</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => addXp(10)} disabled={busy}>
|
||||
+10 XP
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => addXp(50)} disabled={busy}>
|
||||
+50 XP
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => addXp(200)} disabled={busy}>
|
||||
+200 XP
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Level buttons */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Blobbi Level</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={addLevel} disabled={busy}>
|
||||
+1 Level
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Reset */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Reset</p>
|
||||
<Button size="sm" variant="destructive" onClick={resetProgression} disabled={busy}>
|
||||
Reset Blobbi Progression
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Last result */}
|
||||
{lastResult && (
|
||||
<>
|
||||
<Separator />
|
||||
<p className="text-xs font-mono text-muted-foreground break-all">{lastResult}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -38,3 +38,6 @@ export { useBlobbiDevUpdate } from './useBlobbiDevUpdate';
|
||||
export { EmotionDevProvider } from './EmotionDevContext';
|
||||
export { useEmotionDev, useEffectiveEmotion } from './useEmotionDev';
|
||||
export { BlobbiEmotionPanel } from './BlobbiEmotionPanel';
|
||||
|
||||
// Progression testing tools
|
||||
export { ProgressionDevPanel } from './ProgressionDevPanel';
|
||||
|
||||
@@ -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,547 @@
|
||||
// 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, TrendingUp,
|
||||
} 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,
|
||||
setShowProgressionPanel,
|
||||
} = 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>
|
||||
<button onClick={() => { setShowBlobbisPanel(false); setShowProgressionPanel(true); }} className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors">
|
||||
<TrendingUp className="size-5" />
|
||||
<span className="text-[10px]">Level</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,244 @@
|
||||
// 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, type CSSProperties } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
|
||||
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]);
|
||||
|
||||
// ─── Destination labels for nav arrows ───
|
||||
const leftDest = ROOM_META[getPreviousRoom(nav.current, roomOrder)];
|
||||
const rightDest = ROOM_META[getNextRoom(nav.current, roomOrder)];
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
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 with destination labels ── */}
|
||||
<button
|
||||
onClick={goLeft}
|
||||
className={cn(
|
||||
'group absolute left-0 top-1/2 -translate-y-1/2 z-40',
|
||||
'flex items-center gap-0',
|
||||
'text-muted-foreground/40 hover:text-foreground/70',
|
||||
'transition-all duration-200 active:scale-95',
|
||||
'cursor-pointer select-none',
|
||||
'rounded-r-full pl-0.5 pr-1 py-1',
|
||||
'hover:bg-accent/40',
|
||||
)}
|
||||
aria-label={`Go to ${leftDest.label}`}
|
||||
>
|
||||
<ChevronLeft
|
||||
className="size-5 shrink-0 transition-transform duration-300 group-hover:scale-110"
|
||||
style={{ animation: 'room-arrow-nudge-left 2.5s ease-in-out infinite' } as CSSProperties}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium leading-none whitespace-nowrap',
|
||||
'transition-all duration-200',
|
||||
isMobile
|
||||
? 'max-w-[60px] opacity-60'
|
||||
: 'max-w-0 opacity-0 group-hover:max-w-[80px] group-hover:opacity-70 group-focus-visible:max-w-[80px] group-focus-visible:opacity-70',
|
||||
'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{leftDest.label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goRight}
|
||||
className={cn(
|
||||
'group absolute right-0 top-1/2 -translate-y-1/2 z-40',
|
||||
'flex items-center gap-0',
|
||||
'text-muted-foreground/40 hover:text-foreground/70',
|
||||
'transition-all duration-200 active:scale-95',
|
||||
'cursor-pointer select-none',
|
||||
'rounded-l-full pr-0.5 pl-1 py-1',
|
||||
'hover:bg-accent/40',
|
||||
)}
|
||||
aria-label={`Go to ${rightDest.label}`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium leading-none whitespace-nowrap',
|
||||
'transition-all duration-200',
|
||||
isMobile
|
||||
? 'max-w-[60px] opacity-60'
|
||||
: 'max-w-0 opacity-0 group-hover:max-w-[80px] group-hover:opacity-70 group-focus-visible:max-w-[80px] group-focus-visible:opacity-70',
|
||||
'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{rightDest.label}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="size-5 shrink-0 transition-transform duration-300 group-hover:scale-110"
|
||||
style={{ animation: 'room-arrow-nudge-right 2.5s ease-in-out infinite' } as CSSProperties}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)]';
|
||||
@@ -0,0 +1,196 @@
|
||||
// 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>>;
|
||||
showProgressionPanel: boolean;
|
||||
setShowProgressionPanel: 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,
|
||||
});
|
||||
|
||||
|
||||
@@ -699,3 +699,14 @@
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Room navigation arrow nudge — subtle horizontal pulse */
|
||||
@keyframes room-arrow-nudge-left {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
50% { transform: translateX(-3px); }
|
||||
}
|
||||
|
||||
@keyframes room-arrow-nudge-right {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
50% { transform: translateX(3px); }
|
||||
}
|
||||
|
||||
|
||||
+217
-1161
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user