Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 418ba446db | |||
| 76dcf41cc9 | |||
| 5c3ebd8dfd | |||
| e90d657b98 | |||
| a26f5ae626 | |||
| 4c9afe6963 | |||
| b2536bfe64 | |||
| 7e57d1ad6b | |||
| 290d40ac2e | |||
| 3b72cd88cc | |||
| 96387d9941 | |||
| 9848d84f4f | |||
| ff758b078c | |||
| fe5221e973 | |||
| c48079406d | |||
| 76623cd510 | |||
| f1b0868e30 | |||
| ffdf6f0f36 | |||
| c965ff27c4 | |||
| f5f7c90ce4 | |||
| 4e5dbed3d2 | |||
| 508a16234f | |||
| 4ecb3209bd | |||
| 286572777b | |||
| 7fd4b7ab69 | |||
| c9525a0233 | |||
| 0b9cd5e1cb | |||
| a2600d1caa | |||
| 0722d900a2 | |||
| 918814371c |
@@ -21,6 +21,7 @@ These event kinds were created by community contributors and are supported by Di
|
||||
| 7516 | Found Log | Log entry recording a user finding a geocache | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
|
||||
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery | [NIP](https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md) |
|
||||
| 11125 | Blobbonaut Profile | Owner profile with coins, achievements, and inventory | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 11127 | Blobbi House | Room layout, scenes, and furniture for a user's Blobbi house | See [Kind 11127](#kind-11127-blobbi-house) below |
|
||||
| 14919 | Blobbi Interaction | Individual pet interaction (feed, play, clean, etc.) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 14920 | Blobbi Breeding | Breeding event between two adult Blobbis | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 14921 | Blobbi Record | Immutable lifecycle record (birth, evolution, adoption) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
@@ -361,3 +362,89 @@ Kind 16158 (replaceable) describes a weather station's configuration: name, geoh
|
||||
|
||||
NIP-BB defines a virtual pet lifecycle on Nostr. Kind 31124 (addressable) holds the current pet state across three stages (egg, baby, adult) with stats, appearance, and personality traits. Kind 14919 logs individual interactions, kind 14920 records breeding events, kind 14921 stores immutable lifecycle records, and kind 11125 (replaceable) holds the owner's profile with coins, achievements, and inventory.
|
||||
|
||||
---
|
||||
|
||||
## Kind 11127: Blobbi House
|
||||
|
||||
### Summary
|
||||
|
||||
Replaceable event (kind range 10000–19999) that stores the layout, room scenes, and placed items for a user's Blobbi house. One house per user, identified by a canonical `d` tag derived from the user's pubkey.
|
||||
|
||||
Kind 11127 is the source of truth for all room visual data (wall/floor styles, theme color preferences) and will hold furniture placement in a future phase. Daily missions, progression, and inventory remain in kind 11125 (Blobbonaut Profile).
|
||||
|
||||
### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 11127,
|
||||
"content": "{\"version\":1,\"meta\":{\"schema\":\"blobbi-house/v1\",\"name\":\"Blobbi House\"},\"layout\":{\"roomOrder\":[\"care\",\"kitchen\",\"home\",\"hatchery\",\"rest\"],\"rooms\":{\"home\":{\"label\":\"Home\",\"enabled\":true,\"scene\":{\"useThemeColors\":false,\"wall\":{\"type\":\"paint\",\"color\":\"#f5f0eb\"},\"floor\":{\"type\":\"wood\",\"color\":\"#c4a882\",\"accentColor\":\"#a08060\"}},\"items\":[]}}}}",
|
||||
"tags": [
|
||||
["d", "blobbi-house-abcdef012345"],
|
||||
["b", "blobbi:ecosystem:v1"],
|
||||
["name", "Blobbi House"],
|
||||
["version", "1"],
|
||||
["alt", "Blobbi House — room layout, scenes, and furniture"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Content
|
||||
|
||||
The `content` field is a JSON string with the following schema:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------------------|----------|----------|------------------------------------------------------|
|
||||
| `version` | number | Yes | Schema version (currently `1`) |
|
||||
| `meta.schema` | string | Yes | Schema identifier: `"blobbi-house/v1"` |
|
||||
| `meta.name` | string | Yes | Display name for the house |
|
||||
| `layout.roomOrder` | string[] | Yes | Ordered list of room IDs for navigation |
|
||||
| `layout.rooms` | object | Yes | Room definitions keyed by room ID |
|
||||
|
||||
Each room in `layout.rooms` has:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-----------|----------|----------|-----------------------------------------------|
|
||||
| `label` | string | Yes | Human-readable room name |
|
||||
| `enabled` | boolean | Yes | Whether the room is visible |
|
||||
| `scene` | object | Yes | Room scene (wall, floor, theme colors) |
|
||||
| `items` | array | Yes | Placed furniture/items (empty for now) |
|
||||
|
||||
Room scene shape:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------------|---------|----------|----------------------------------------------------|
|
||||
| `useThemeColors` | boolean | Yes | Whether to derive colors from the active app theme |
|
||||
| `wall.type` | string | Yes | Wall surface: `"paint"`, `"wallpaper"`, `"brick"` |
|
||||
| `wall.color` | string | Yes | Hex color (e.g. `"#f5f0eb"`) |
|
||||
| `wall.accentColor` | string | No | Hex accent color for patterns |
|
||||
| `floor.type` | string | Yes | Floor surface: `"wood"`, `"tile"`, `"carpet"` |
|
||||
| `floor.color` | string | Yes | Hex color |
|
||||
| `floor.accentColor` | string | No | Hex accent color for grain/grout |
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|-----------|----------|----------------------------------------------------------------------------|
|
||||
| `d` | Yes | Canonical identifier: `blobbi-house-{first 12 chars of pubkey}` |
|
||||
| `b` | Yes | Blobbi ecosystem marker: `blobbi:ecosystem:v1` |
|
||||
| `name` | Yes | Human-readable house name |
|
||||
| `version` | Yes | Schema version string |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback description |
|
||||
|
||||
### D-Tag Strategy
|
||||
|
||||
The `d` tag is deterministic: `blobbi-house-` + the first 12 hex characters of the user's pubkey. This allows lookup without knowing the event ID. One house per user is enforced by the replaceable event semantics (kind 10000–19999: latest event per pubkey+kind wins).
|
||||
|
||||
### Migration from Kind 11125
|
||||
|
||||
Prior to kind 11127, room scene data was stored in the `roomCustomization` section of kind 11125 content. On first load, if no kind 11127 event exists, the client checks kind 11125 for legacy `roomCustomization` data and migrates it into a new kind 11127 event. Kind 11125 is never mutated during migration.
|
||||
|
||||
### Client Behavior
|
||||
|
||||
- Query: `{ kinds: [11127], authors: [pubkey], "#d": ["blobbi-house-{prefix}"], limit: 1 }`
|
||||
- On first visit (no house): auto-bootstrap with default rooms
|
||||
- All room scene reads come from kind 11127
|
||||
- All room scene writes go to kind 11127 (read-modify-write with `fetchFreshEvent`)
|
||||
- Unknown top-level keys in the content are preserved across writes
|
||||
- Unknown rooms in `layout.rooms` are preserved (forward compatibility)
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// src/blobbi/house/hooks/useBlobbiHouse.ts
|
||||
|
||||
/**
|
||||
* useBlobbiHouse — Fetches, bootstraps, and manages the Blobbi House
|
||||
* root event (kind 11127).
|
||||
*
|
||||
* ── Data flow ────────────────────────────────────────────────────────
|
||||
*
|
||||
* 1. Query for existing kind 11127 event for the logged-in user.
|
||||
* 2. If found, parse and validate the content → use as house data.
|
||||
* 3. If not found AND profile event is available, check kind 11125
|
||||
* for legacy `roomCustomization` data and merge it into a new house.
|
||||
* 4. If not found AND no legacy data, build a fresh default house.
|
||||
* 5. Return the parsed house content + the raw event for write-back.
|
||||
*
|
||||
* ── Bootstrap safety ─────────────────────────────────────────────────
|
||||
*
|
||||
* Bootstrap only fires when ALL of:
|
||||
* - The house query has settled (not loading, not fetching)
|
||||
* - No house event was found
|
||||
* - The profile event has been provided (not undefined = still loading)
|
||||
* - No bootstrap is already in progress (guarded by ref)
|
||||
*
|
||||
* This prevents duplicate publishes across re-mounts and avoids
|
||||
* firing before the profile is ready (which would miss legacy data).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { KIND_BLOBBI_HOUSE, buildHouseDTag, buildHouseTags } from '../lib/house-constants';
|
||||
import { parseHouseContent } from '../lib/house-content';
|
||||
import { buildDefaultHouseContent } from '../lib/house-defaults';
|
||||
import { resolveHouseBootstrap } from '../lib/house-migration';
|
||||
import type { BlobbiHouseContent } from '../lib/house-types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseBlobbiHouseResult {
|
||||
/** The parsed house content, or null while loading. */
|
||||
house: BlobbiHouseContent | null;
|
||||
/** The raw house event, or null if not yet fetched/created. */
|
||||
houseEvent: NostrEvent | null;
|
||||
/** Whether the house is still loading (query + bootstrap). */
|
||||
isLoading: boolean;
|
||||
/** Optimistic cache update — call after publishing a new house event. */
|
||||
updateHouseEvent: (event: NostrEvent) => void;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useBlobbiHouse(
|
||||
profileEvent: NostrEvent | null | undefined,
|
||||
): UseBlobbiHouseResult {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const pubkey = user?.pubkey;
|
||||
|
||||
// ── Fetch existing house event ──
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['blobbi-house', pubkey],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!pubkey) return null;
|
||||
|
||||
const events = await nostr.query(
|
||||
[{
|
||||
kinds: [KIND_BLOBBI_HOUSE],
|
||||
authors: [pubkey],
|
||||
'#d': [buildHouseDTag(pubkey)],
|
||||
limit: 1,
|
||||
}],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
return events.reduce((latest, current) =>
|
||||
current.created_at > latest.created_at ? current : latest,
|
||||
);
|
||||
},
|
||||
enabled: !!pubkey,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const houseEvent = query.data ?? null;
|
||||
|
||||
// ── Bootstrap: create house if it doesn't exist ──
|
||||
//
|
||||
// Guard: `bootstrapInFlightRef` prevents concurrent bootstrap attempts.
|
||||
// Unlike a simple "attempted" flag, this is only set while a publish is
|
||||
// in-flight and is cleared on completion (success or failure). This means:
|
||||
// - Re-mounts don't skip bootstrap if the previous attempt failed
|
||||
// - Concurrent attempts are prevented during in-flight publishes
|
||||
// - Successful bootstrap is reflected via the cache update (not the ref)
|
||||
|
||||
const bootstrapInFlightRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for the query to fully settle
|
||||
if (!pubkey || query.isLoading || query.isFetching) return;
|
||||
|
||||
// House already exists — nothing to do
|
||||
if (houseEvent) return;
|
||||
|
||||
// Profile event must be explicitly available (null = no profile, undefined = still loading).
|
||||
// If undefined, we wait — bootstrap will re-evaluate when profileEvent arrives.
|
||||
if (profileEvent === undefined) return;
|
||||
|
||||
// Already bootstrapping — don't fire again
|
||||
if (bootstrapInFlightRef.current) return;
|
||||
|
||||
const { content, needsPublish } = resolveHouseBootstrap(
|
||||
null, // No house event
|
||||
profileEvent,
|
||||
);
|
||||
|
||||
if (!needsPublish) return;
|
||||
|
||||
bootstrapInFlightRef.current = true;
|
||||
|
||||
// Publish the new house event
|
||||
publishEvent({
|
||||
kind: KIND_BLOBBI_HOUSE,
|
||||
content: JSON.stringify(content),
|
||||
tags: buildHouseTags(pubkey),
|
||||
}).then((event) => {
|
||||
// Optimistically set in cache so the query picks it up
|
||||
queryClient.setQueryData(['blobbi-house', pubkey], event);
|
||||
}).catch((err) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[useBlobbiHouse] Failed to bootstrap house:', err);
|
||||
}
|
||||
}).finally(() => {
|
||||
bootstrapInFlightRef.current = false;
|
||||
});
|
||||
}, [pubkey, query.isLoading, query.isFetching, houseEvent, profileEvent, publishEvent, queryClient]);
|
||||
|
||||
// ── Parse house content ──
|
||||
|
||||
const house = useMemo((): BlobbiHouseContent | null => {
|
||||
if (!houseEvent) return null;
|
||||
return parseHouseContent(houseEvent.content) ?? buildDefaultHouseContent();
|
||||
}, [houseEvent]);
|
||||
|
||||
// ── Optimistic update callback ──
|
||||
|
||||
const updateHouseEvent = useCallback((event: NostrEvent) => {
|
||||
if (!pubkey) return;
|
||||
queryClient.setQueryData(['blobbi-house', pubkey], event);
|
||||
}, [pubkey, queryClient]);
|
||||
|
||||
// Loading is true while the query hasn't settled OR while bootstrap is pending.
|
||||
// "Bootstrap pending" = query settled with no result and profile not yet available.
|
||||
const isBootstrapPending = !query.isLoading && !houseEvent && profileEvent === undefined;
|
||||
const isLoading = query.isLoading || isBootstrapPending;
|
||||
|
||||
return {
|
||||
house,
|
||||
houseEvent,
|
||||
isLoading,
|
||||
updateHouseEvent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// src/blobbi/house/index.ts — barrel export
|
||||
|
||||
// ── Constants ──
|
||||
export {
|
||||
KIND_BLOBBI_HOUSE,
|
||||
HOUSE_SCHEMA,
|
||||
HOUSE_VERSION,
|
||||
HOUSE_DEFAULT_NAME,
|
||||
buildHouseDTag,
|
||||
buildHouseTags,
|
||||
} from './lib/house-constants';
|
||||
|
||||
// ── Types ──
|
||||
export type {
|
||||
HouseItemKind,
|
||||
HouseItemPlane,
|
||||
HouseItemLayer,
|
||||
HouseItemPosition,
|
||||
HouseItem,
|
||||
HouseRoomScene,
|
||||
HouseRoom,
|
||||
HouseLayout,
|
||||
HouseMeta,
|
||||
BlobbiHouseContent,
|
||||
} from './lib/house-types';
|
||||
|
||||
// ── Defaults ──
|
||||
export {
|
||||
DEFAULT_ROOMS,
|
||||
DEFAULT_ROOM_ORDER,
|
||||
buildDefaultHouseContent,
|
||||
getDefaultRoomScene,
|
||||
isKnownRoomId,
|
||||
deriveNavigableRooms,
|
||||
} from './lib/house-defaults';
|
||||
|
||||
// ── Content Helpers ──
|
||||
export {
|
||||
parseHouseContent,
|
||||
updateHouseRoomScene,
|
||||
patchHouseRoomScene,
|
||||
resetHouseRoomScene,
|
||||
getRoomSceneFromHouse,
|
||||
} from './lib/house-content';
|
||||
|
||||
// ── Migration ──
|
||||
export {
|
||||
extractLegacyRoomCustomization,
|
||||
buildHouseWithLegacyData,
|
||||
resolveHouseBootstrap,
|
||||
} from './lib/house-migration';
|
||||
|
||||
// ── Hooks ──
|
||||
export { useBlobbiHouse, type UseBlobbiHouseResult } from './hooks/useBlobbiHouse';
|
||||
@@ -0,0 +1,97 @@
|
||||
// src/blobbi/house/items/BuiltinItemVisual.tsx
|
||||
|
||||
/**
|
||||
* BuiltinItemVisual — Renders a builtin catalog item as inline SVG/CSS.
|
||||
*
|
||||
* Each builtin item ID maps to a small, self-contained visual.
|
||||
* These are intentionally simple for Phase 1 — future phases will
|
||||
* support external SVG assets and Nostr event references.
|
||||
*/
|
||||
|
||||
interface BuiltinItemVisualProps {
|
||||
/** Catalog item ID. */
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a builtin item visual that fills its parent container.
|
||||
* The parent is expected to provide width/height via CSS.
|
||||
*/
|
||||
export function BuiltinItemVisual({ id }: BuiltinItemVisualProps) {
|
||||
const visual = ITEM_VISUALS[id];
|
||||
if (!visual) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full pointer-events-none select-none">
|
||||
{visual}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Visual Registry ──────────────────────────────────────────────────────────
|
||||
|
||||
const ITEM_VISUALS: Record<string, React.ReactNode> = {
|
||||
poster_abstract: <PosterAbstract />,
|
||||
rug_round: <RugRound />,
|
||||
plant_potted: <PlantPotted />,
|
||||
};
|
||||
|
||||
// ─── Individual Item Visuals ──────────────────────────────────────────────────
|
||||
|
||||
/** A framed abstract poster on the wall. */
|
||||
function PosterAbstract() {
|
||||
return (
|
||||
<svg viewBox="0 0 80 110" className="w-full h-full" aria-label="Abstract poster">
|
||||
{/* Frame */}
|
||||
<rect x="2" y="2" width="76" height="106" rx="3" fill="#f5f0eb" stroke="#b8a28e" strokeWidth="2.5" />
|
||||
{/* Inner mat */}
|
||||
<rect x="8" y="8" width="64" height="94" rx="1" fill="#fff" />
|
||||
{/* Abstract shapes */}
|
||||
<circle cx="30" cy="40" r="16" fill="#e8927c" opacity="0.85" />
|
||||
<circle cx="52" cy="55" r="12" fill="#7cb5e8" opacity="0.75" />
|
||||
<rect x="20" y="65" width="40" height="8" rx="4" fill="#c4d88e" opacity="0.7" />
|
||||
<circle cx="40" cy="35" r="8" fill="#f0c86e" opacity="0.6" />
|
||||
{/* Hanging wire */}
|
||||
<path d="M 30 0 Q 40 -6 50 0" fill="none" stroke="#999" strokeWidth="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** A round decorative rug on the floor. */
|
||||
function RugRound() {
|
||||
return (
|
||||
<svg viewBox="0 0 200 100" className="w-full h-full" aria-label="Round rug">
|
||||
{/* Rug shape — ellipse to look like a circle in perspective */}
|
||||
<ellipse cx="100" cy="50" rx="95" ry="45" fill="#c4866e" opacity="0.55" />
|
||||
<ellipse cx="100" cy="50" rx="80" ry="38" fill="#d4a08e" opacity="0.5" />
|
||||
{/* Inner pattern rings */}
|
||||
<ellipse cx="100" cy="50" rx="60" ry="28" fill="none" stroke="#e8c4b0" strokeWidth="2" opacity="0.6" />
|
||||
<ellipse cx="100" cy="50" rx="40" ry="19" fill="none" stroke="#e8c4b0" strokeWidth="1.5" opacity="0.5" />
|
||||
{/* Center dot */}
|
||||
<ellipse cx="100" cy="50" rx="8" ry="4" fill="#b87460" opacity="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** A small potted plant. */
|
||||
function PlantPotted() {
|
||||
return (
|
||||
<svg viewBox="0 0 60 100" className="w-full h-full" aria-label="Potted plant">
|
||||
{/* Pot */}
|
||||
<path d="M 16 65 L 20 95 Q 30 100 40 95 L 44 65 Z" fill="#c4866e" />
|
||||
<rect x="13" y="60" width="34" height="8" rx="3" fill="#d4976e" />
|
||||
{/* Soil */}
|
||||
<ellipse cx="30" cy="64" rx="14" ry="4" fill="#6b4e3d" />
|
||||
{/* Stems */}
|
||||
<path d="M 30 62 Q 28 45 22 35" fill="none" stroke="#5a8a4a" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<path d="M 30 62 Q 32 42 38 30" fill="none" stroke="#5a8a4a" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<path d="M 30 62 Q 30 48 30 28" fill="none" stroke="#5a8a4a" strokeWidth="2" strokeLinecap="round" />
|
||||
{/* Leaves */}
|
||||
<ellipse cx="20" cy="32" rx="10" ry="6" fill="#6aad58" transform="rotate(-25 20 32)" opacity="0.9" />
|
||||
<ellipse cx="40" cy="28" rx="10" ry="6" fill="#78ba65" transform="rotate(20 40 28)" opacity="0.85" />
|
||||
<ellipse cx="30" cy="25" rx="8" ry="5" fill="#82c470" transform="rotate(-5 30 25)" opacity="0.8" />
|
||||
<ellipse cx="24" cy="42" rx="7" ry="4" fill="#6aad58" transform="rotate(-40 24 42)" opacity="0.7" />
|
||||
<ellipse cx="36" cy="38" rx="7" ry="4" fill="#78ba65" transform="rotate(35 36 38)" opacity="0.75" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// src/blobbi/house/items/RoomItemsLayer.tsx
|
||||
|
||||
/**
|
||||
* RoomItemsLayer — Renders placed items in a room using layer-based z-ordering.
|
||||
*
|
||||
* ── Rendering model ──────────────────────────────────────────────────
|
||||
*
|
||||
* Wall items (wallBack, wallDecor) are rendered as flat absolutely-
|
||||
* positioned elements over the full room viewport. Their coordinates
|
||||
* map into the wall area (top 60%).
|
||||
*
|
||||
* Floor items (backFloor, frontFloor) are rendered inside a perspective-
|
||||
* transformed container that matches the floor scene geometry from
|
||||
* RoomSceneLayer. This makes floor items visually belong to the same
|
||||
* receding floor plane — they foreshorten and scale naturally instead
|
||||
* of feeling pasted on top.
|
||||
*
|
||||
* Overlay items render flat over the full viewport (above everything).
|
||||
*
|
||||
* ── Layer z-stack (back to front) ────────────────────────────────────
|
||||
*
|
||||
* z 1: wallBack — behind wall texture
|
||||
* z 2: wallDecor — on wall surface (posters, shelves)
|
||||
* z 4: backFloor — floor behind Blobbi (rugs)
|
||||
* z 5: (Blobbi hero — not rendered here)
|
||||
* z 6: frontFloor — floor in front of Blobbi (plants, tables)
|
||||
* z 8: overlay — floating above everything
|
||||
*/
|
||||
|
||||
import type { HouseItem, HouseItemLayer } from '../lib/house-types';
|
||||
import {
|
||||
WALL_PERCENT,
|
||||
FLOOR_PERSPECTIVE,
|
||||
FLOOR_TILT,
|
||||
FLOOR_OVERFLOW,
|
||||
} from '@/blobbi/rooms/scene/components/RoomSceneLayer';
|
||||
import { getCatalogItem } from './item-catalog';
|
||||
import { toScreenPosition, toScreenSize } from './item-coordinates';
|
||||
import { BuiltinItemVisual } from './BuiltinItemVisual';
|
||||
|
||||
// ─── Layer Configuration ──────────────────────────────────────────────────────
|
||||
|
||||
const FLOOR_PERCENT = 100 - WALL_PERCENT;
|
||||
|
||||
const LAYER_Z: Record<HouseItemLayer, number> = {
|
||||
wallBack: 1,
|
||||
wallDecor: 2,
|
||||
backFloor: 4,
|
||||
blobbi: 5,
|
||||
frontFloor: 6,
|
||||
overlay: 8,
|
||||
};
|
||||
|
||||
/** Wall layers: rendered flat over the full room viewport. */
|
||||
const WALL_LAYERS: HouseItemLayer[] = ['wallBack', 'wallDecor'];
|
||||
|
||||
/** Floor layers: rendered inside a perspective-transformed container. */
|
||||
const FLOOR_LAYERS: HouseItemLayer[] = ['backFloor', 'frontFloor'];
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RoomItemsLayerProps {
|
||||
/** The items to render (from house.layout.rooms[roomId].items). */
|
||||
items: HouseItem[];
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function RoomItemsLayer({ items }: RoomItemsLayerProps) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
// Group visible items by layer
|
||||
const byLayer = new Map<HouseItemLayer, HouseItem[]>();
|
||||
for (const item of items) {
|
||||
if (!item.visible) continue;
|
||||
if (item.layer === 'blobbi') continue;
|
||||
const list = byLayer.get(item.layer);
|
||||
if (list) list.push(item);
|
||||
else byLayer.set(item.layer, [item]);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Wall layers: flat, positioned over full room viewport ── */}
|
||||
{WALL_LAYERS.map((layerId) => {
|
||||
const layerItems = byLayer.get(layerId);
|
||||
if (!layerItems || layerItems.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={layerId}
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{ zIndex: LAYER_Z[layerId] }}
|
||||
>
|
||||
{layerItems.map((item) => (
|
||||
<RoomItem key={item.instanceId} item={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ── Floor layers: inside perspective-transformed container ── */}
|
||||
{FLOOR_LAYERS.map((layerId) => {
|
||||
const layerItems = byLayer.get(layerId);
|
||||
if (!layerItems || layerItems.length === 0) return null;
|
||||
return (
|
||||
<FloorItemLayer
|
||||
key={layerId}
|
||||
layerId={layerId}
|
||||
items={layerItems}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ── Overlay: flat, above everything ── */}
|
||||
{(() => {
|
||||
const overlayItems = byLayer.get('overlay');
|
||||
if (!overlayItems || overlayItems.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{ zIndex: LAYER_Z.overlay }}
|
||||
>
|
||||
{overlayItems.map((item) => (
|
||||
<RoomItem key={item.instanceId} item={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Floor Item Layer ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A floor item layer that replicates the floor scene's perspective geometry.
|
||||
*
|
||||
* Structure (matches RoomSceneLayer's floor area):
|
||||
* outer div — covers the floor zone, applies perspective
|
||||
* inner div — tilted plane (rotateX), items positioned inside
|
||||
*
|
||||
* Items use floor-local coordinates (0..1000 → 0%..100% of the tilted surface).
|
||||
*/
|
||||
function FloorItemLayer({
|
||||
layerId,
|
||||
items,
|
||||
}: {
|
||||
layerId: HouseItemLayer;
|
||||
items: HouseItem[];
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-x-0 pointer-events-none"
|
||||
style={{
|
||||
top: `${WALL_PERCENT}%`,
|
||||
height: `${FLOOR_PERCENT}%`,
|
||||
perspective: FLOOR_PERSPECTIVE,
|
||||
perspectiveOrigin: '50% 0%',
|
||||
zIndex: LAYER_Z[layerId],
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
transformOrigin: 'top center',
|
||||
transform: FLOOR_TILT,
|
||||
height: FLOOR_OVERFLOW,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<RoomItem key={item.instanceId} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Single Item Renderer ─────────────────────────────────────────────────────
|
||||
|
||||
function RoomItem({ item }: { item: HouseItem }) {
|
||||
const catalog = getCatalogItem(item.id);
|
||||
if (!catalog) return null;
|
||||
|
||||
const pos = toScreenPosition(item.position, item.plane);
|
||||
const size = toScreenSize(catalog.width, catalog.height, item.plane);
|
||||
|
||||
const transforms: string[] = ['translate(-50%, -50%)'];
|
||||
if (item.scale !== 1) transforms.push(`scale(${item.scale})`);
|
||||
if (item.rotation !== 0) transforms.push(`rotate(${item.rotation}deg)`);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: pos.left,
|
||||
top: pos.top,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
transform: transforms.join(' '),
|
||||
}}
|
||||
data-item-id={item.instanceId}
|
||||
>
|
||||
{item.kind === 'builtin' && (
|
||||
<BuiltinItemVisual id={item.id} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// src/blobbi/house/items/index.ts — barrel export
|
||||
|
||||
export { RoomItemsLayer } from './RoomItemsLayer';
|
||||
export { BuiltinItemVisual } from './BuiltinItemVisual';
|
||||
export { BUILTIN_ITEMS, getCatalogItem, type CatalogItem } from './item-catalog';
|
||||
export {
|
||||
toScreenPosition,
|
||||
toWallPosition,
|
||||
toFloorPosition,
|
||||
toScreenSize,
|
||||
type ScreenPosition,
|
||||
} from './item-coordinates';
|
||||
@@ -0,0 +1,67 @@
|
||||
// src/blobbi/house/items/item-catalog.ts
|
||||
|
||||
/**
|
||||
* Builtin Item Catalog — Static registry of items that can be placed in rooms.
|
||||
*
|
||||
* Each catalog entry defines the visual appearance and default placement
|
||||
* properties for an item. The catalog is keyed by item ID.
|
||||
*
|
||||
* For Phase 1, all items are `builtin` kind with inline SVG/CSS rendering.
|
||||
* Future phases may add `svg` (external SVG URL) or `event-ref` (Nostr event).
|
||||
*/
|
||||
|
||||
import type { HouseItemPlane, HouseItemLayer } from '../lib/house-types';
|
||||
|
||||
// ─── Catalog Entry ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CatalogItem {
|
||||
/** Unique catalog ID (matches HouseItem.id). */
|
||||
id: string;
|
||||
/** Display name. */
|
||||
name: string;
|
||||
/** Default plane for this item. */
|
||||
plane: HouseItemPlane;
|
||||
/** Default render layer. */
|
||||
layer: HouseItemLayer;
|
||||
/** Base width in the normalized coordinate space (0..1000). */
|
||||
width: number;
|
||||
/** Base height in the normalized coordinate space (0..1000). */
|
||||
height: number;
|
||||
}
|
||||
|
||||
// ─── Builtin Items ────────────────────────────────────────────────────────────
|
||||
|
||||
export const BUILTIN_ITEMS: Record<string, CatalogItem> = {
|
||||
poster_abstract: {
|
||||
id: 'poster_abstract',
|
||||
name: 'Abstract Poster',
|
||||
plane: 'wall',
|
||||
layer: 'wallDecor',
|
||||
width: 120,
|
||||
height: 160,
|
||||
},
|
||||
rug_round: {
|
||||
id: 'rug_round',
|
||||
name: 'Round Rug',
|
||||
plane: 'floor',
|
||||
layer: 'backFloor',
|
||||
width: 280,
|
||||
height: 140,
|
||||
},
|
||||
plant_potted: {
|
||||
id: 'plant_potted',
|
||||
name: 'Potted Plant',
|
||||
plane: 'floor',
|
||||
layer: 'frontFloor',
|
||||
width: 100,
|
||||
height: 160,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up a catalog entry by item ID.
|
||||
* Returns undefined for unknown items (they're rendered as invisible placeholders).
|
||||
*/
|
||||
export function getCatalogItem(id: string): CatalogItem | undefined {
|
||||
return BUILTIN_ITEMS[id];
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// src/blobbi/house/items/item-coordinates.ts
|
||||
|
||||
/**
|
||||
* Item Coordinate System — Maps normalized (0..1000) positions to screen %.
|
||||
*
|
||||
* ── Coordinate spaces ────────────────────────────────────────────────
|
||||
*
|
||||
* Persisted: { x: 0..1000, y: 0..1000 }
|
||||
* Each plane has its own independent coordinate space.
|
||||
* x=0 is left edge, x=1000 is right edge.
|
||||
* y=0 is the top of the plane, y=1000 is the bottom.
|
||||
*
|
||||
* ── Wall items ───────────────────────────────────────────────────────
|
||||
*
|
||||
* Wall item layers are absolutely positioned over the full room viewport.
|
||||
* Positions map to the wall area (top 60% of the viewport):
|
||||
* left = (x / 1000) * 100 %
|
||||
* top = (y / 1000) * WALL_PERCENT %
|
||||
*
|
||||
* ── Floor items ──────────────────────────────────────────────────────
|
||||
*
|
||||
* Floor item layers live INSIDE a perspective-transformed container
|
||||
* that matches the floor scene geometry. Their coordinate space is
|
||||
* local to the tilted floor surface:
|
||||
* left = (x / 1000) * 100 %
|
||||
* top = (y / 1000) * 100 %
|
||||
*
|
||||
* Because these percentages are relative to the tilted inner div,
|
||||
* items naturally foreshorten with the floor — no extra math needed.
|
||||
*
|
||||
* ── Centering ────────────────────────────────────────────────────────
|
||||
*
|
||||
* Items are positioned with `transform: translate(-50%, -50%)` so the
|
||||
* position represents the item's center point, not its top-left corner.
|
||||
*/
|
||||
|
||||
import { WALL_PERCENT } from '@/blobbi/rooms/scene/components/RoomSceneLayer';
|
||||
import type { HouseItemPlane, HouseItemPosition } from '../lib/house-types';
|
||||
|
||||
// ─── Normalized → Screen CSS ──────────────────────────────────────────────────
|
||||
|
||||
export interface ScreenPosition {
|
||||
/** CSS left value (e.g. '50%'). */
|
||||
left: string;
|
||||
/** CSS top value (e.g. '35%'). */
|
||||
top: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a normalized (0..1000) wall-item position to CSS percentages.
|
||||
*
|
||||
* The returned values are relative to the full room viewport.
|
||||
* Wall items map y into the wall area (0% → WALL_PERCENT%).
|
||||
*/
|
||||
export function toWallPosition(pos: HouseItemPosition): ScreenPosition {
|
||||
return {
|
||||
left: `${(pos.x / 1000) * 100}%`,
|
||||
top: `${(pos.y / 1000) * WALL_PERCENT}%`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a normalized (0..1000) floor-item position to CSS percentages.
|
||||
*
|
||||
* The returned values are relative to the perspective-transformed
|
||||
* floor container (not the full room viewport). Since the floor
|
||||
* container already covers only the floor zone, both x and y map
|
||||
* directly to 0%..100%.
|
||||
*/
|
||||
export function toFloorPosition(pos: HouseItemPosition): ScreenPosition {
|
||||
return {
|
||||
left: `${(pos.x / 1000) * 100}%`,
|
||||
top: `${(pos.y / 1000) * 100}%`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a normalized (0..1000) position to CSS percentages.
|
||||
* Dispatches to the plane-specific helper.
|
||||
*/
|
||||
export function toScreenPosition(pos: HouseItemPosition, plane: HouseItemPlane): ScreenPosition {
|
||||
return plane === 'wall' ? toWallPosition(pos) : toFloorPosition(pos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a normalized size (0..1000) to CSS percentage width/height.
|
||||
*
|
||||
* Wall items: width relative to full room, height relative to wall area.
|
||||
* Floor items: width and height relative to the floor container.
|
||||
*/
|
||||
export function toScreenSize(
|
||||
width: number,
|
||||
height: number,
|
||||
plane: HouseItemPlane,
|
||||
): { width: string; height: string } {
|
||||
const wPercent = (width / 1000) * 100;
|
||||
if (plane === 'wall') {
|
||||
const hPercent = (height / 1000) * WALL_PERCENT;
|
||||
return { width: `${wPercent}%`, height: `${hPercent}%` };
|
||||
}
|
||||
// Floor items: both dimensions are relative to the floor container
|
||||
const hPercent = (height / 1000) * 100;
|
||||
return { width: `${wPercent}%`, height: `${hPercent}%` };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// src/blobbi/house/lib/house-constants.ts
|
||||
|
||||
/**
|
||||
* Blobbi House — Constants and canonical identifiers.
|
||||
*
|
||||
* Kind 11127 is a replaceable event (10000–19999 range) that stores
|
||||
* the Blobbi House root: room layout, room scenes, and (future) furniture.
|
||||
*
|
||||
* One house per user, identified by a canonical d-tag derived from
|
||||
* the user's pubkey prefix.
|
||||
*/
|
||||
|
||||
/** Kind number for the Blobbi House root event. */
|
||||
export const KIND_BLOBBI_HOUSE = 11127;
|
||||
|
||||
/** Schema identifier embedded in the content `meta` block. */
|
||||
export const HOUSE_SCHEMA = 'blobbi-house/v1';
|
||||
|
||||
/** Current content version. Bump when the schema changes. */
|
||||
export const HOUSE_VERSION = 1;
|
||||
|
||||
/** Default house display name. */
|
||||
export const HOUSE_DEFAULT_NAME = 'Blobbi House';
|
||||
|
||||
/**
|
||||
* Build the canonical d-tag for a user's Blobbi House.
|
||||
*
|
||||
* Format: `blobbi-house-{first12CharsOfPubkey}`
|
||||
*
|
||||
* This is deterministic — the same pubkey always produces the same d-tag,
|
||||
* so the house can be looked up without knowing the event ID.
|
||||
*/
|
||||
export function buildHouseDTag(pubkey: string): string {
|
||||
return `blobbi-house-${pubkey.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the standard tags array for a Blobbi House event.
|
||||
*
|
||||
* Tags:
|
||||
* ["d", "blobbi-house-{pubkeyPrefix}"]
|
||||
* ["b", "blobbi:ecosystem:v1"]
|
||||
* ["name", "Blobbi House"]
|
||||
* ["version", "1"]
|
||||
* ["alt", "Blobbi House — room layout, scenes, and furniture"]
|
||||
*/
|
||||
export function buildHouseTags(pubkey: string): string[][] {
|
||||
return [
|
||||
['d', buildHouseDTag(pubkey)],
|
||||
['b', 'blobbi:ecosystem:v1'],
|
||||
['name', HOUSE_DEFAULT_NAME],
|
||||
['version', String(HOUSE_VERSION)],
|
||||
['alt', 'Blobbi House \u2014 room layout, scenes, and furniture'],
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// src/blobbi/house/lib/house-content.ts
|
||||
|
||||
/**
|
||||
* Blobbi House — Content parsing, validation, and safe update helpers.
|
||||
*
|
||||
* All reads and writes go through this module to ensure:
|
||||
* 1. Unknown top-level keys are preserved
|
||||
* 2. Unknown rooms are preserved
|
||||
* 3. Editing one room preserves siblings
|
||||
* 4. Editing scene preserves items (and vice versa)
|
||||
* 5. Invalid/corrupt content is handled gracefully
|
||||
*/
|
||||
|
||||
import type { WallConfig, FloorConfig } from '@/blobbi/rooms/scene/types';
|
||||
import type {
|
||||
BlobbiHouseContent,
|
||||
HouseRoom,
|
||||
HouseRoomScene,
|
||||
HouseItem,
|
||||
HouseLayout,
|
||||
} from './house-types';
|
||||
import { buildDefaultHouseContent, DEFAULT_ROOMS } from './house-defaults';
|
||||
|
||||
// ─── Validation Constants ─────────────────────────────────────────────────────
|
||||
|
||||
const VALID_WALL_TYPES = new Set(['paint', 'wallpaper', 'brick']);
|
||||
const VALID_FLOOR_TYPES = new Set(['wood', 'tile', 'carpet']);
|
||||
const HEX_COLOR_RE = /^#[0-9a-fA-F]{3,8}$/;
|
||||
|
||||
// ─── Validation Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function isHexColor(v: unknown): v is string {
|
||||
return typeof v === 'string' && HEX_COLOR_RE.test(v);
|
||||
}
|
||||
|
||||
function validateWallConfig(raw: unknown): WallConfig | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
if (typeof obj.type !== 'string' || !VALID_WALL_TYPES.has(obj.type)) return null;
|
||||
if (!isHexColor(obj.color)) return null;
|
||||
return {
|
||||
type: obj.type as WallConfig['type'],
|
||||
color: obj.color,
|
||||
...(isHexColor(obj.accentColor) ? { accentColor: obj.accentColor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateFloorConfig(raw: unknown): FloorConfig | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
if (typeof obj.type !== 'string' || !VALID_FLOOR_TYPES.has(obj.type)) return null;
|
||||
if (!isHexColor(obj.color)) return null;
|
||||
return {
|
||||
type: obj.type as FloorConfig['type'],
|
||||
color: obj.color,
|
||||
...(isHexColor(obj.accentColor) ? { accentColor: obj.accentColor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateScene(raw: unknown): HouseRoomScene | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const wall = validateWallConfig(obj.wall);
|
||||
const floor = validateFloorConfig(obj.floor);
|
||||
if (!wall || !floor) return null;
|
||||
return { useThemeColors: obj.useThemeColors === true, wall, floor };
|
||||
}
|
||||
|
||||
function validateItems(raw: unknown): HouseItem[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
// For Phase 1, we preserve items as-is if they're objects.
|
||||
// Full item validation will come with the furniture phase.
|
||||
return raw.filter(
|
||||
(item): item is HouseItem =>
|
||||
!!item && typeof item === 'object' && !Array.isArray(item) &&
|
||||
typeof (item as Record<string, unknown>).instanceId === 'string',
|
||||
);
|
||||
}
|
||||
|
||||
function validateRoom(raw: unknown, roomId: string): HouseRoom | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
const scene = validateScene(obj.scene);
|
||||
if (!scene) return null;
|
||||
|
||||
return {
|
||||
label: typeof obj.label === 'string' ? obj.label : roomId,
|
||||
enabled: obj.enabled !== false, // default true
|
||||
scene,
|
||||
items: validateItems(obj.items),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Parsing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse and validate Blobbi House content from a raw JSON string.
|
||||
*
|
||||
* Returns a validated `BlobbiHouseContent` or null if the content
|
||||
* is fundamentally invalid (not JSON, not an object, missing layout).
|
||||
*
|
||||
* Individual rooms with invalid scene data are silently dropped
|
||||
* (they'll get defaults on next write). Unknown rooms are preserved.
|
||||
*/
|
||||
export function parseHouseContent(content: string): BlobbiHouseContent | null {
|
||||
if (!content || content.trim() === '') return null;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
// Version check
|
||||
const version = typeof obj.version === 'number' ? obj.version : 1;
|
||||
|
||||
// Meta
|
||||
const rawMeta = obj.meta as Record<string, unknown> | undefined;
|
||||
const meta = {
|
||||
schema: typeof rawMeta?.schema === 'string' ? rawMeta.schema : 'blobbi-house/v1',
|
||||
name: typeof rawMeta?.name === 'string' ? rawMeta.name : 'Blobbi House',
|
||||
};
|
||||
|
||||
// Layout
|
||||
const rawLayout = obj.layout;
|
||||
if (!rawLayout || typeof rawLayout !== 'object' || Array.isArray(rawLayout)) return null;
|
||||
const layoutObj = rawLayout as Record<string, unknown>;
|
||||
|
||||
// Room order
|
||||
const roomOrder = Array.isArray(layoutObj.roomOrder)
|
||||
? (layoutObj.roomOrder as unknown[]).filter((id): id is string => typeof id === 'string')
|
||||
: [];
|
||||
|
||||
// Rooms map
|
||||
const rooms: Record<string, HouseRoom> = {};
|
||||
const rawRooms = layoutObj.rooms;
|
||||
if (rawRooms && typeof rawRooms === 'object' && !Array.isArray(rawRooms)) {
|
||||
for (const [roomId, roomData] of Object.entries(rawRooms as Record<string, unknown>)) {
|
||||
const validated = validateRoom(roomData, roomId);
|
||||
if (validated) {
|
||||
rooms[roomId] = validated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have neither room order nor rooms, the content is fundamentally empty
|
||||
if (roomOrder.length === 0 && Object.keys(rooms).length === 0) return null;
|
||||
|
||||
// If roomOrder is empty but rooms exist, derive order from the rooms map.
|
||||
// This handles partial data gracefully (e.g., manual edits, future migrations).
|
||||
const effectiveRoomOrder = roomOrder.length > 0
|
||||
? roomOrder
|
||||
: Object.keys(rooms);
|
||||
|
||||
return { version, meta, layout: { roomOrder: effectiveRoomOrder, rooms } };
|
||||
}
|
||||
|
||||
// ─── Safe Content Update Helpers ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Safely parse house content, falling back to defaults.
|
||||
* Always returns a valid BlobbiHouseContent.
|
||||
*/
|
||||
function safeParseHouse(content: string): { data: Record<string, unknown>; house: BlobbiHouseContent } {
|
||||
let raw: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
raw = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to defaults
|
||||
}
|
||||
|
||||
const house = parseHouseContent(content) ?? buildDefaultHouseContent();
|
||||
return { data: raw, house };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single room's scene in the house content.
|
||||
*
|
||||
* Safety guarantees:
|
||||
* 1. All other top-level keys preserved (version, meta, unknown)
|
||||
* 2. All other rooms preserved
|
||||
* 3. Items within the target room preserved
|
||||
* 4. roomOrder preserved
|
||||
*/
|
||||
export function updateHouseRoomScene(
|
||||
existingContent: string,
|
||||
roomId: string,
|
||||
scene: HouseRoomScene,
|
||||
): string {
|
||||
const { data, house } = safeParseHouse(existingContent);
|
||||
|
||||
const existingRoom = house.layout.rooms[roomId] ?? DEFAULT_ROOMS[roomId];
|
||||
const updatedRoom: HouseRoom = existingRoom
|
||||
? { ...existingRoom, scene }
|
||||
: { label: roomId, enabled: true, scene, items: [] };
|
||||
|
||||
const updatedRooms = { ...house.layout.rooms, [roomId]: updatedRoom };
|
||||
const updatedLayout: HouseLayout = { ...house.layout, rooms: updatedRooms };
|
||||
|
||||
return JSON.stringify({
|
||||
...data,
|
||||
version: house.version,
|
||||
meta: house.meta,
|
||||
layout: updatedLayout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Partially update a single room's scene in the house content.
|
||||
*
|
||||
* Only the provided fields in the patch are changed. Everything else
|
||||
* (other rooms, items, roomOrder, useThemeColors when not patched) is preserved.
|
||||
*/
|
||||
export function patchHouseRoomScene(
|
||||
existingContent: string,
|
||||
roomId: string,
|
||||
patch: Partial<{ useThemeColors: boolean; wall: Partial<WallConfig>; floor: Partial<FloorConfig> }>,
|
||||
fallbackScene: HouseRoomScene,
|
||||
): string {
|
||||
const { data, house } = safeParseHouse(existingContent);
|
||||
|
||||
const existingRoom = house.layout.rooms[roomId];
|
||||
const existingScene = existingRoom?.scene ?? fallbackScene;
|
||||
|
||||
const mergedScene: HouseRoomScene = {
|
||||
useThemeColors: patch.useThemeColors ?? existingScene.useThemeColors,
|
||||
wall: { ...existingScene.wall, ...(patch.wall ?? {}) } as WallConfig,
|
||||
floor: { ...existingScene.floor, ...(patch.floor ?? {}) } as FloorConfig,
|
||||
};
|
||||
|
||||
const updatedRoom: HouseRoom = existingRoom
|
||||
? { ...existingRoom, scene: mergedScene }
|
||||
: { label: roomId, enabled: true, scene: mergedScene, items: [] };
|
||||
|
||||
const updatedRooms = { ...house.layout.rooms, [roomId]: updatedRoom };
|
||||
const updatedLayout: HouseLayout = { ...house.layout, rooms: updatedRooms };
|
||||
|
||||
return JSON.stringify({
|
||||
...data,
|
||||
version: house.version,
|
||||
meta: house.meta,
|
||||
layout: updatedLayout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a room's scene customization, resetting it to defaults.
|
||||
*
|
||||
* If the room has a built-in default, it's replaced with that default.
|
||||
* If the room has no default, it's removed from the rooms map entirely.
|
||||
* Other rooms, items, roomOrder, and unknown keys are preserved.
|
||||
*/
|
||||
export function resetHouseRoomScene(
|
||||
existingContent: string,
|
||||
roomId: string,
|
||||
): string {
|
||||
const { data, house } = safeParseHouse(existingContent);
|
||||
|
||||
const defaultRoom = DEFAULT_ROOMS[roomId];
|
||||
const updatedRooms = { ...house.layout.rooms };
|
||||
|
||||
if (defaultRoom) {
|
||||
// Reset to default, preserving items
|
||||
const existingItems = updatedRooms[roomId]?.items ?? [];
|
||||
updatedRooms[roomId] = { ...structuredClone(defaultRoom), items: existingItems };
|
||||
} else {
|
||||
delete updatedRooms[roomId];
|
||||
}
|
||||
|
||||
const updatedLayout: HouseLayout = { ...house.layout, rooms: updatedRooms };
|
||||
|
||||
return JSON.stringify({
|
||||
...data,
|
||||
version: house.version,
|
||||
meta: house.meta,
|
||||
layout: updatedLayout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scene for a specific room from house content.
|
||||
* Returns the room's scene or undefined if the room doesn't exist.
|
||||
*/
|
||||
export function getRoomSceneFromHouse(
|
||||
content: string,
|
||||
roomId: string,
|
||||
): HouseRoomScene | undefined {
|
||||
const house = parseHouseContent(content);
|
||||
return house?.layout.rooms[roomId]?.scene;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// src/blobbi/house/lib/house-defaults.ts
|
||||
|
||||
/**
|
||||
* Blobbi House — Default house content and room definitions.
|
||||
*
|
||||
* These defaults are used when creating a new house (kind 11127)
|
||||
* for a user who doesn't have one yet.
|
||||
*
|
||||
* Each room has a distinct default scene that matches its personality.
|
||||
*/
|
||||
|
||||
import {
|
||||
HOUSE_SCHEMA,
|
||||
HOUSE_VERSION,
|
||||
HOUSE_DEFAULT_NAME,
|
||||
} from './house-constants';
|
||||
import type {
|
||||
BlobbiHouseContent,
|
||||
HouseItem,
|
||||
HouseRoom,
|
||||
HouseRoomScene,
|
||||
} from './house-types';
|
||||
|
||||
// ─── Default Scenes per Room ──────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_HOME_SCENE: HouseRoomScene = {
|
||||
useThemeColors: false,
|
||||
wall: { type: 'paint', color: '#f5f0eb' },
|
||||
floor: { type: 'wood', color: '#c4a882', accentColor: '#a08060' },
|
||||
};
|
||||
|
||||
const DEFAULT_KITCHEN_SCENE: HouseRoomScene = {
|
||||
useThemeColors: false,
|
||||
wall: { type: 'brick', color: '#f0ebe5', accentColor: '#d4cdc4' },
|
||||
floor: { type: 'tile', color: '#c9947a', accentColor: '#a67560' },
|
||||
};
|
||||
|
||||
const DEFAULT_CARE_SCENE: HouseRoomScene = {
|
||||
useThemeColors: false,
|
||||
wall: { type: 'paint', color: '#e8eff5' },
|
||||
floor: { type: 'tile', color: '#e2ddd6', accentColor: '#c8c0b4' },
|
||||
};
|
||||
|
||||
const DEFAULT_HATCHERY_SCENE: HouseRoomScene = {
|
||||
useThemeColors: false,
|
||||
wall: { type: 'wallpaper', color: '#e6ddd1', accentColor: '#b8a890' },
|
||||
floor: { type: 'carpet', color: '#6b5e52' },
|
||||
};
|
||||
|
||||
const DEFAULT_REST_SCENE: HouseRoomScene = {
|
||||
useThemeColors: false,
|
||||
wall: { type: 'paint', color: '#d6d0de' },
|
||||
floor: { type: 'carpet', color: '#8a7e96' },
|
||||
};
|
||||
|
||||
const DEFAULT_CLOSET_SCENE: HouseRoomScene = {
|
||||
useThemeColors: false,
|
||||
wall: { type: 'paint', color: '#f0ece8' },
|
||||
floor: { type: 'wood', color: '#b8a28e', accentColor: '#9a8672' },
|
||||
};
|
||||
|
||||
// ─── Default Home Room Items ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Starter furniture for the home room.
|
||||
*
|
||||
* Positions are in the normalized 0..1000 coordinate space:
|
||||
* x: 0 = left edge, 1000 = right edge
|
||||
* y: 0 = top of the plane, 1000 = bottom of the plane
|
||||
*
|
||||
* Wall items use the wall plane (y maps to the wall area).
|
||||
* Floor items use the floor plane (y maps to the floor area).
|
||||
*/
|
||||
const DEFAULT_HOME_ITEMS: HouseItem[] = [
|
||||
{
|
||||
id: 'poster_abstract',
|
||||
instanceId: 'home-poster-1',
|
||||
kind: 'builtin',
|
||||
plane: 'wall',
|
||||
layer: 'wallDecor',
|
||||
position: { x: 250, y: 350 },
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'rug_round',
|
||||
instanceId: 'home-rug-1',
|
||||
kind: 'builtin',
|
||||
plane: 'floor',
|
||||
layer: 'backFloor',
|
||||
position: { x: 500, y: 350 },
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'plant_potted',
|
||||
instanceId: 'home-plant-1',
|
||||
kind: 'builtin',
|
||||
plane: 'floor',
|
||||
layer: 'frontFloor',
|
||||
position: { x: 820, y: 500 },
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Default Room Definitions ─────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_ROOMS: Record<string, HouseRoom> = {
|
||||
care: {
|
||||
label: 'Care Room',
|
||||
enabled: true,
|
||||
scene: DEFAULT_CARE_SCENE,
|
||||
items: [],
|
||||
},
|
||||
kitchen: {
|
||||
label: 'Kitchen',
|
||||
enabled: true,
|
||||
scene: DEFAULT_KITCHEN_SCENE,
|
||||
items: [],
|
||||
},
|
||||
home: {
|
||||
label: 'Home',
|
||||
enabled: true,
|
||||
scene: DEFAULT_HOME_SCENE,
|
||||
items: structuredClone(DEFAULT_HOME_ITEMS),
|
||||
},
|
||||
hatchery: {
|
||||
label: 'Hatchery',
|
||||
enabled: true,
|
||||
scene: DEFAULT_HATCHERY_SCENE,
|
||||
items: [],
|
||||
},
|
||||
rest: {
|
||||
label: 'Bedroom',
|
||||
enabled: true,
|
||||
scene: DEFAULT_REST_SCENE,
|
||||
items: [],
|
||||
},
|
||||
closet: {
|
||||
label: 'Closet',
|
||||
enabled: true,
|
||||
scene: DEFAULT_CLOSET_SCENE,
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
|
||||
/** Default room order (closet excluded for now). */
|
||||
export const DEFAULT_ROOM_ORDER: string[] = [
|
||||
'care', 'kitchen', 'home', 'hatchery', 'rest',
|
||||
];
|
||||
|
||||
// ─── Default House Builder ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a complete default house content object.
|
||||
*
|
||||
* Used when:
|
||||
* - Creating a brand new house for a first-time user
|
||||
* - As fallback when the house event content is invalid
|
||||
*/
|
||||
export function buildDefaultHouseContent(): BlobbiHouseContent {
|
||||
return {
|
||||
version: HOUSE_VERSION,
|
||||
meta: {
|
||||
schema: HOUSE_SCHEMA,
|
||||
name: HOUSE_DEFAULT_NAME,
|
||||
},
|
||||
layout: {
|
||||
roomOrder: [...DEFAULT_ROOM_ORDER],
|
||||
rooms: structuredClone(DEFAULT_ROOMS),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default scene for a room ID.
|
||||
* Returns undefined if the room has no built-in default.
|
||||
*/
|
||||
export function getDefaultRoomScene(roomId: string): HouseRoomScene | undefined {
|
||||
return DEFAULT_ROOMS[roomId]?.scene;
|
||||
}
|
||||
|
||||
// ─── Navigable Room Derivation ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The set of room IDs that have both a registered component and metadata.
|
||||
* Any ID from house data that is NOT in this set is silently ignored.
|
||||
*
|
||||
* Kept in sync with `ROOM_META` / `ROOM_COMPONENTS` in the rooms layer.
|
||||
* We intentionally duplicate the set here (as plain strings) to avoid
|
||||
* importing from the rooms layer and creating a circular dependency.
|
||||
*/
|
||||
const KNOWN_ROOM_IDS = new Set<string>([
|
||||
'care', 'kitchen', 'home', 'hatchery', 'rest', 'closet',
|
||||
]);
|
||||
|
||||
/** Type-guard: is `id` a known room ID string? */
|
||||
export function isKnownRoomId(id: string): boolean {
|
||||
return KNOWN_ROOM_IDS.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the final navigable room list from house content.
|
||||
*
|
||||
* Rules applied (in order):
|
||||
* 1. Start from `house.layout.roomOrder`.
|
||||
* 2. Keep only IDs that exist in `KNOWN_ROOM_IDS` (drop future/unknown).
|
||||
* 3. Keep only IDs whose room entry has `enabled !== false`.
|
||||
* 4. If the result is empty, fall back to `DEFAULT_ROOM_ORDER`.
|
||||
*
|
||||
* The returned array is safe to use directly for navigation, dots, and
|
||||
* prev/next helpers — no further filtering needed downstream.
|
||||
*/
|
||||
export function deriveNavigableRooms(
|
||||
house: { layout: { roomOrder: string[]; rooms: Record<string, { enabled: boolean }> } } | null,
|
||||
): string[] {
|
||||
if (!house) return [...DEFAULT_ROOM_ORDER];
|
||||
|
||||
const { roomOrder, rooms } = house.layout;
|
||||
|
||||
const navigable = roomOrder.filter((id) => {
|
||||
if (!KNOWN_ROOM_IDS.has(id)) return false;
|
||||
// A room not present in the rooms map is treated as enabled (default true).
|
||||
const room = rooms[id];
|
||||
return !room || room.enabled !== false;
|
||||
});
|
||||
|
||||
return navigable.length > 0 ? navigable : [...DEFAULT_ROOM_ORDER];
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// src/blobbi/house/lib/house-migration.ts
|
||||
|
||||
/**
|
||||
* Blobbi House — Migration helpers for moving room scene data
|
||||
* from kind 11125 (profile) into kind 11127 (house).
|
||||
*
|
||||
* ── Migration behavior ──────────────────────────────────────────────
|
||||
*
|
||||
* 1. If 11127 already exists → use it as-is, no migration needed.
|
||||
* 2. If 11127 does not exist → build a default house, then check
|
||||
* 11125 for legacy `roomCustomization` data and merge it in.
|
||||
* 3. Legacy data is read conservatively — invalid entries are skipped.
|
||||
* 4. 11125 is never mutated during migration.
|
||||
*/
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { safeParseContent } from '@/blobbi/core/lib/content-json';
|
||||
import type { RoomScene } from '@/blobbi/rooms/scene/types';
|
||||
import type { HouseRoomScene, BlobbiHouseContent } from './house-types';
|
||||
import { buildDefaultHouseContent, DEFAULT_ROOMS } from './house-defaults';
|
||||
import { parseHouseContent } from './house-content';
|
||||
|
||||
// ─── Legacy Data Reader ───────────────────────────────────────────────────────
|
||||
|
||||
const VALID_WALL_TYPES = new Set(['paint', 'wallpaper', 'brick']);
|
||||
const VALID_FLOOR_TYPES = new Set(['wood', 'tile', 'carpet']);
|
||||
const HEX_COLOR_RE = /^#[0-9a-fA-F]{3,8}$/;
|
||||
|
||||
function isHex(v: unknown): v is string {
|
||||
return typeof v === 'string' && HEX_COLOR_RE.test(v);
|
||||
}
|
||||
|
||||
function validateLegacyScene(raw: unknown): RoomScene | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
// Wall
|
||||
const wall = obj.wall as Record<string, unknown> | undefined;
|
||||
if (!wall || typeof wall !== 'object' || Array.isArray(wall)) return null;
|
||||
if (typeof wall.type !== 'string' || !VALID_WALL_TYPES.has(wall.type)) return null;
|
||||
if (!isHex(wall.color)) return null;
|
||||
|
||||
// Floor
|
||||
const floor = obj.floor as Record<string, unknown> | undefined;
|
||||
if (!floor || typeof floor !== 'object' || Array.isArray(floor)) return null;
|
||||
if (typeof floor.type !== 'string' || !VALID_FLOOR_TYPES.has(floor.type)) return null;
|
||||
if (!isHex(floor.color)) return null;
|
||||
|
||||
return {
|
||||
useThemeColors: obj.useThemeColors === true,
|
||||
wall: {
|
||||
type: wall.type as 'paint' | 'wallpaper' | 'brick',
|
||||
color: wall.color,
|
||||
...(isHex(wall.accentColor) ? { accentColor: wall.accentColor } : {}),
|
||||
},
|
||||
floor: {
|
||||
type: floor.type as 'wood' | 'tile' | 'carpet',
|
||||
color: floor.color,
|
||||
...(isHex(floor.accentColor) ? { accentColor: floor.accentColor } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract legacy `roomCustomization` data from kind 11125 content.
|
||||
*
|
||||
* Returns a map of roomId → RoomScene for rooms with valid customization.
|
||||
* Returns null if no valid legacy data exists.
|
||||
*/
|
||||
export function extractLegacyRoomCustomization(
|
||||
profileContent: string,
|
||||
): Record<string, RoomScene> | null {
|
||||
const { data } = safeParseContent(profileContent);
|
||||
const rc = data.roomCustomization;
|
||||
|
||||
if (!rc || typeof rc !== 'object' || Array.isArray(rc)) return null;
|
||||
|
||||
const result: Record<string, RoomScene> = {};
|
||||
let hasEntries = false;
|
||||
|
||||
for (const [roomId, raw] of Object.entries(rc as Record<string, unknown>)) {
|
||||
const validated = validateLegacyScene(raw);
|
||||
if (validated) {
|
||||
result[roomId] = validated;
|
||||
hasEntries = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasEntries ? result : null;
|
||||
}
|
||||
|
||||
// ─── Migration: Build House from Legacy Data ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a legacy RoomScene into a HouseRoomScene.
|
||||
* The types are compatible, this is just a type bridge.
|
||||
*/
|
||||
function legacySceneToHouseScene(scene: RoomScene): HouseRoomScene {
|
||||
return {
|
||||
useThemeColors: scene.useThemeColors,
|
||||
wall: { ...scene.wall },
|
||||
floor: { ...scene.floor },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a house content object, optionally incorporating legacy
|
||||
* room customization data from kind 11125.
|
||||
*
|
||||
* Rooms with legacy data get their scene replaced.
|
||||
* Rooms without legacy data keep their defaults.
|
||||
* Items, labels, enabled state are all defaults (legacy had none).
|
||||
*/
|
||||
export function buildHouseWithLegacyData(
|
||||
legacyScenes: Record<string, RoomScene>,
|
||||
): BlobbiHouseContent {
|
||||
const house = buildDefaultHouseContent();
|
||||
|
||||
for (const [roomId, scene] of Object.entries(legacyScenes)) {
|
||||
const defaultRoom = house.layout.rooms[roomId] ?? DEFAULT_ROOMS[roomId];
|
||||
if (defaultRoom) {
|
||||
house.layout.rooms[roomId] = {
|
||||
...defaultRoom,
|
||||
scene: legacySceneToHouseScene(scene),
|
||||
};
|
||||
} else {
|
||||
// Unknown room from legacy data — preserve it
|
||||
house.layout.rooms[roomId] = {
|
||||
label: roomId,
|
||||
enabled: true,
|
||||
scene: legacySceneToHouseScene(scene),
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return house;
|
||||
}
|
||||
|
||||
// ─── Bootstrap Decision ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Determine the initial house content for a user.
|
||||
*
|
||||
* @param houseEvent - The existing kind 11127 event, or null
|
||||
* @param profileEvent - The existing kind 11125 event, or null
|
||||
* @returns The house content to use, and whether a new event needs publishing
|
||||
*/
|
||||
export function resolveHouseBootstrap(
|
||||
houseEvent: NostrEvent | null,
|
||||
profileEvent: NostrEvent | null,
|
||||
): { content: BlobbiHouseContent; needsPublish: boolean } {
|
||||
// Case 1: House already exists — use it
|
||||
if (houseEvent) {
|
||||
const parsed = parseHouseContent(houseEvent.content);
|
||||
if (parsed) {
|
||||
return { content: parsed, needsPublish: false };
|
||||
}
|
||||
// House event exists but content is corrupt — rebuild from scratch
|
||||
// (fall through to bootstrap)
|
||||
}
|
||||
|
||||
// Case 2: No house event — check for legacy data in profile
|
||||
if (profileEvent) {
|
||||
const legacyScenes = extractLegacyRoomCustomization(profileEvent.content);
|
||||
if (legacyScenes) {
|
||||
return {
|
||||
content: buildHouseWithLegacyData(legacyScenes),
|
||||
needsPublish: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: No house, no legacy data — fresh default
|
||||
return {
|
||||
content: buildDefaultHouseContent(),
|
||||
needsPublish: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// src/blobbi/house/lib/house-types.ts
|
||||
|
||||
/**
|
||||
* Blobbi House — Type definitions for the house root event content.
|
||||
*
|
||||
* The house root (kind 11127) stores the room layout, room scenes,
|
||||
* and (future) furniture placement for a user's Blobbi house.
|
||||
*
|
||||
* ── Schema overview ──────────────────────────────────────────────────
|
||||
*
|
||||
* {
|
||||
* "version": 1,
|
||||
* "meta": { "schema": "blobbi-house/v1", "name": "Blobbi House" },
|
||||
* "layout": {
|
||||
* "roomOrder": ["care", "kitchen", "home", ...],
|
||||
* "rooms": {
|
||||
* "home": {
|
||||
* "label": "Home",
|
||||
* "enabled": true,
|
||||
* "scene": { wall, floor, useThemeColors },
|
||||
* "items": []
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
import type { WallConfig, FloorConfig } from '@/blobbi/rooms/scene/types';
|
||||
|
||||
// ─── Item Types (future-ready) ────────────────────────────────────────────────
|
||||
|
||||
/** The source/origin of a placeable item. */
|
||||
export type HouseItemKind = 'builtin' | 'svg' | 'event-ref';
|
||||
|
||||
/** The spatial plane an item lives on. */
|
||||
export type HouseItemPlane = 'wall' | 'floor';
|
||||
|
||||
/**
|
||||
* Render layer — controls draw order within the room.
|
||||
*
|
||||
* From back to front:
|
||||
* wallBack → behind the wall (rarely used)
|
||||
* wallDecor → on the wall surface (posters, shelves)
|
||||
* backFloor → on the floor behind Blobbi (rugs, back furniture)
|
||||
* blobbi → the Blobbi layer (never used for items, reserved)
|
||||
* frontFloor → on the floor in front of Blobbi (tables, plants)
|
||||
* overlay → above everything (floating decorations, particles)
|
||||
*/
|
||||
export type HouseItemLayer =
|
||||
| 'wallBack'
|
||||
| 'wallDecor'
|
||||
| 'backFloor'
|
||||
| 'blobbi'
|
||||
| 'frontFloor'
|
||||
| 'overlay';
|
||||
|
||||
/**
|
||||
* Normalized logical position.
|
||||
*
|
||||
* Range: 0..1000 for both axes.
|
||||
* Never store raw viewport pixels in persisted data.
|
||||
* Renderers map 0..1000 to the actual room viewport at render time.
|
||||
*/
|
||||
export interface HouseItemPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** A single placed item in a room. */
|
||||
export interface HouseItem {
|
||||
/** Item catalog ID (e.g. "plant_basic_1"). */
|
||||
id: string;
|
||||
/** Unique instance ID within this room (e.g. "home-item-1"). */
|
||||
instanceId: string;
|
||||
/** Source type. */
|
||||
kind: HouseItemKind;
|
||||
/** Which plane the item lives on. */
|
||||
plane: HouseItemPlane;
|
||||
/** Render layer for draw order. */
|
||||
layer: HouseItemLayer;
|
||||
/** Normalized position (0..1000). */
|
||||
position: HouseItemPosition;
|
||||
/** Scale factor (1 = default). */
|
||||
scale: number;
|
||||
/** Rotation in degrees. */
|
||||
rotation: number;
|
||||
/** Whether the item is currently visible. */
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
// ─── Room Scene (reused from existing scene types) ────────────────────────────
|
||||
|
||||
/** Room scene configuration — same shape as the existing RoomScene type. */
|
||||
export interface HouseRoomScene {
|
||||
/** Whether to derive colors from the active app theme. */
|
||||
useThemeColors: boolean;
|
||||
/** Wall configuration. */
|
||||
wall: WallConfig;
|
||||
/** Floor configuration. */
|
||||
floor: FloorConfig;
|
||||
}
|
||||
|
||||
// ─── Room Definition ──────────────────────────────────────────────────────────
|
||||
|
||||
/** A single room definition within the house. */
|
||||
export interface HouseRoom {
|
||||
/** Human-readable label. */
|
||||
label: string;
|
||||
/** Whether this room is enabled/visible. */
|
||||
enabled: boolean;
|
||||
/** Room scene (wall, floor, theme colors). */
|
||||
scene: HouseRoomScene;
|
||||
/** Placed items in this room (empty for Phase 1). */
|
||||
items: HouseItem[];
|
||||
}
|
||||
|
||||
// ─── House Layout ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** The layout section — room order + room definitions. */
|
||||
export interface HouseLayout {
|
||||
/** Ordered list of room IDs for navigation. */
|
||||
roomOrder: string[];
|
||||
/** Room definitions keyed by room ID. */
|
||||
rooms: Record<string, HouseRoom>;
|
||||
}
|
||||
|
||||
// ─── House Meta ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Metadata block in the house content. */
|
||||
export interface HouseMeta {
|
||||
/** Schema identifier. */
|
||||
schema: string;
|
||||
/** User-facing house name. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ─── House Root ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The complete Blobbi House root content.
|
||||
*
|
||||
* This is the shape of `event.content` for kind 11127.
|
||||
* Unknown top-level keys are preserved during read/write.
|
||||
*/
|
||||
export interface BlobbiHouseContent {
|
||||
/** Content version number. */
|
||||
version: number;
|
||||
/** Metadata block. */
|
||||
meta: HouseMeta;
|
||||
/** Room layout and definitions. */
|
||||
layout: HouseLayout;
|
||||
}
|
||||
@@ -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,216 @@
|
||||
// src/blobbi/rooms/components/BlobbiHomeRoom.tsx
|
||||
|
||||
/**
|
||||
* BlobbiHomeRoom — The main living / play room.
|
||||
*
|
||||
* Layout:
|
||||
* - Room scene background (wall + floor with perspective)
|
||||
* - 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, useState } from 'react';
|
||||
import { Camera, Footprints, Music, Mic, Paintbrush } 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';
|
||||
import {
|
||||
RoomSceneLayer,
|
||||
useRoomScene,
|
||||
useRoomSceneEditor,
|
||||
RoomCustomizeSheet,
|
||||
} from '../scene';
|
||||
import { RoomItemsLayer } from '@/blobbi/house/items';
|
||||
|
||||
interface BlobbiHomeRoomProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
poopState: RoomPoopState;
|
||||
}
|
||||
|
||||
export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) {
|
||||
const {
|
||||
house,
|
||||
houseEvent,
|
||||
updateHouseEvent,
|
||||
isActiveFloatingCompanion,
|
||||
setShowPhotoModal,
|
||||
isCurrentCompanion,
|
||||
canBeCompanion,
|
||||
isUpdatingCompanion,
|
||||
handleSetAsCompanion,
|
||||
isUsingItem,
|
||||
usingItemId,
|
||||
handleUseItemFromTab,
|
||||
handleDirectAction,
|
||||
isDirectActionPending,
|
||||
inlineActivity,
|
||||
handleConfirmSing,
|
||||
handleCloseInlineActivity,
|
||||
handleMusicPlaybackStart,
|
||||
handleMusicPlaybackStop,
|
||||
handleSingRecordingStart,
|
||||
handleSingRecordingStop,
|
||||
handleChangeTrack,
|
||||
isPublishing,
|
||||
actionInProgress,
|
||||
} = ctx;
|
||||
|
||||
// ── Room Scene (wall + floor behind Blobbi) — reads from house (kind 11127) ──
|
||||
const roomScene = useRoomScene('home', houseEvent?.content ?? '');
|
||||
|
||||
// ── Room Customization Editor — writes to house (kind 11127) ──
|
||||
const [showCustomize, setShowCustomize] = useState(false);
|
||||
const { scene: rawScene, patchScene, resetScene, isSaving: isSceneSaving } =
|
||||
useRoomSceneEditor('home', houseEvent, updateHouseEvent);
|
||||
|
||||
// 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];
|
||||
}, []);
|
||||
|
||||
// ── Room Items (furniture) — reads from house (kind 11127) ──
|
||||
const homeItems = useMemo(
|
||||
() => house?.layout.rooms.home?.items ?? [],
|
||||
[house],
|
||||
);
|
||||
|
||||
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 relative">
|
||||
{/* ── Room Scene Background (z-index 0) ── */}
|
||||
<RoomSceneLayer scene={roomScene} />
|
||||
|
||||
{/* ── Room Items — layered around Blobbi (z 1-8, hero at 5) ── */}
|
||||
<RoomItemsLayer items={homeItems} />
|
||||
|
||||
{/* ── Decor button (top-right, above room content) ── */}
|
||||
<button
|
||||
onClick={() => setShowCustomize(true)}
|
||||
className="absolute top-2 right-2 z-30 size-8 flex items-center justify-center rounded-full bg-background/50 backdrop-blur-sm text-foreground/60 hover:text-foreground/90 hover:bg-background/70 transition-all shadow-sm"
|
||||
aria-label="Customize room"
|
||||
>
|
||||
<Paintbrush className="size-3.5" />
|
||||
</button>
|
||||
|
||||
{/* ── Hero (Blobbi + stats) — z-index 5, between backFloor and frontFloor ── */}
|
||||
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0 z-[5]" />
|
||||
|
||||
{/* ── 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>
|
||||
)}
|
||||
|
||||
{/* ── Room Customization Sheet ── */}
|
||||
<RoomCustomizeSheet
|
||||
open={showCustomize}
|
||||
onOpenChange={setShowCustomize}
|
||||
currentWallType={rawScene.wall.type}
|
||||
currentWallColor={rawScene.wall.color}
|
||||
currentFloorType={rawScene.floor.type}
|
||||
currentFloorColor={rawScene.floor.color}
|
||||
currentUseThemeColors={rawScene.useThemeColors}
|
||||
onPatch={patchScene}
|
||||
onReset={resetScene}
|
||||
isSaving={isSceneSaving}
|
||||
/>
|
||||
</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,292 @@
|
||||
// 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.
|
||||
// Weighted flex layout: top spacer grows more than bottom spacer
|
||||
// so Blobbi is pushed downward toward the floor plane. This
|
||||
// produces consistent grounding across mobile and desktop.
|
||||
'relative flex flex-col items-center pt-10 px-4 sm:px-6 flex-1 min-h-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Top spacer — grows 3x to push content toward the floor */}
|
||||
<div className="flex-[3_3_0%] min-h-0" />
|
||||
|
||||
<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>
|
||||
|
||||
{/* Bottom spacer — grows 2x; keeps Blobbi off the very bottom edge */}
|
||||
<div className="flex-[2_2_0%] min-h-0" />
|
||||
</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,266 @@
|
||||
// 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_INITIAL_ROOM,
|
||||
getNextRoom,
|
||||
getPreviousRoom,
|
||||
getRoomIndex,
|
||||
} from '../lib/room-config';
|
||||
import { DEFAULT_ROOM_ORDER } from '@/blobbi/house/lib/house-defaults';
|
||||
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;
|
||||
/** Room order — should come from the house event layout. Falls back to defaults. */
|
||||
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 as BlobbiRoomId[],
|
||||
initialRoom = DEFAULT_INITIAL_ROOM,
|
||||
}: BlobbiRoomShellProps) {
|
||||
const [nav, setNav] = useState<RoomNavState>({
|
||||
current: roomOrder.includes(initialRoom) ? initialRoom : roomOrder[0],
|
||||
direction: null,
|
||||
});
|
||||
|
||||
// ── Keep current room valid when roomOrder changes ──
|
||||
// If the current room was removed or disabled, jump to the first available room.
|
||||
// Without this, a stale `nav.current` produces a broken header / missing component.
|
||||
useEffect(() => {
|
||||
setNav(prev => {
|
||||
if (roomOrder.includes(prev.current)) return prev; // still valid
|
||||
return { current: roomOrder[0], direction: null };
|
||||
});
|
||||
}, [roomOrder]);
|
||||
|
||||
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-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 pointer-events-auto',
|
||||
'px-3 py-1 rounded-full',
|
||||
'bg-background/60 backdrop-blur-md',
|
||||
'shadow-sm border border-border/20',
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{meta.icon}</span>
|
||||
<span className="text-xs sm:text-sm font-semibold text-foreground/80">{meta.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1.5">
|
||||
{dots.map(dot => (
|
||||
<div
|
||||
key={dot.id}
|
||||
className={cn(
|
||||
'rounded-full transition-all duration-300',
|
||||
dot.active
|
||||
? 'w-4 h-1.5 bg-primary shadow-sm'
|
||||
: 'w-1.5 h-1.5 bg-foreground/20',
|
||||
)}
|
||||
title={dot.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Left / Right Navigation Arrows with destination labels ── */}
|
||||
<button
|
||||
onClick={goLeft}
|
||||
className={cn(
|
||||
'group absolute left-0.5 top-1/2 -translate-y-1/2 z-40',
|
||||
'flex items-center gap-0',
|
||||
'text-foreground/50 hover:text-foreground/80',
|
||||
'transition-all duration-200 active:scale-95',
|
||||
'cursor-pointer select-none',
|
||||
'rounded-full pl-1 pr-1.5 py-1.5',
|
||||
'bg-background/40 backdrop-blur-sm',
|
||||
'hover:bg-background/60',
|
||||
'shadow-sm',
|
||||
)}
|
||||
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-70'
|
||||
: 'max-w-0 opacity-0 group-hover:max-w-[80px] group-hover:opacity-80 group-focus-visible:max-w-[80px] group-focus-visible:opacity-80',
|
||||
'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{leftDest.label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goRight}
|
||||
className={cn(
|
||||
'group absolute right-0.5 top-1/2 -translate-y-1/2 z-40',
|
||||
'flex items-center gap-0',
|
||||
'text-foreground/50 hover:text-foreground/80',
|
||||
'transition-all duration-200 active:scale-95',
|
||||
'cursor-pointer select-none',
|
||||
'rounded-full pr-1 pl-1.5 py-1.5',
|
||||
'bg-background/40 backdrop-blur-sm',
|
||||
'hover:bg-background/60',
|
||||
'shadow-sm',
|
||||
)}
|
||||
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-70'
|
||||
: 'max-w-0 opacity-0 group-hover:max-w-[80px] group-hover:opacity-80 group-focus-visible:max-w-[80px] group-focus-visible:opacity-80',
|
||||
'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,145 @@
|
||||
// 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 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Navigation fallback room order.
|
||||
*
|
||||
* The canonical room order is stored in the house event (kind 11127)
|
||||
* at `layout.roomOrder`. This constant is a fallback for navigation
|
||||
* helpers when no house-derived order is available (e.g. during
|
||||
* initial load or in contexts without house access).
|
||||
*
|
||||
* The house-level default (`house-defaults.ts`) and this array MUST
|
||||
* stay in sync. Both exclude 'closet' until the wardrobe feature ships.
|
||||
*/
|
||||
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,19 @@
|
||||
// src/blobbi/rooms/lib/room-layout.ts
|
||||
|
||||
/**
|
||||
* Shared layout constants for Blobbi room components.
|
||||
*/
|
||||
|
||||
/**
|
||||
* CSS class for the bottom action bar in every room.
|
||||
*
|
||||
* Includes a semi-transparent frosted background so action buttons remain
|
||||
* readable over the room scene background. The frost is subtle enough to
|
||||
* let the room environment show through.
|
||||
*
|
||||
* 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-3 pb-4 sm:pb-6 max-sidebar:pb-[calc(var(--bottom-nav-height)+env(safe-area-inset-bottom,0px)+1rem)] bg-background/50 backdrop-blur-md';
|
||||
@@ -0,0 +1,207 @@
|
||||
// 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';
|
||||
import type { BlobbiHouseContent } from '@/blobbi/house/lib/house-types';
|
||||
import type { BlobbiRoomId } from './room-config';
|
||||
|
||||
// ─── 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;
|
||||
|
||||
// ── House (kind 11127) ──
|
||||
/** The parsed house content, or null while loading. */
|
||||
house: BlobbiHouseContent | null;
|
||||
/** The raw house event — needed by write hooks (useRoomSceneEditor). */
|
||||
houseEvent: NostrEvent | null;
|
||||
updateHouseEvent: (event: NostrEvent) => void;
|
||||
/** Room order derived from the house layout. */
|
||||
roomOrder: BlobbiRoomId[];
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// src/blobbi/rooms/scene/components/FloorLayer.tsx
|
||||
|
||||
/**
|
||||
* FloorLayer — Renders the floor surface with visual depth.
|
||||
*
|
||||
* The floor receives CSS 3D perspective from its parent container
|
||||
* (RoomSceneLayer). This component renders the surface pattern only.
|
||||
* Different floor types produce different textures:
|
||||
*
|
||||
* - wood: Horizontal planks with grain lines and color variation
|
||||
* - tile: Checkerboard/grid pattern
|
||||
* - carpet: Solid textured surface
|
||||
*
|
||||
* The component fills its parent container entirely.
|
||||
*/
|
||||
|
||||
import { useMemo, useId } from 'react';
|
||||
import { darkenHex, lightenHex, blendHex } from '@/lib/colorUtils';
|
||||
import type { FloorConfig } from '../types';
|
||||
|
||||
interface FloorLayerProps {
|
||||
config: FloorConfig;
|
||||
}
|
||||
|
||||
export function FloorLayer({ config }: FloorLayerProps) {
|
||||
const { type, color, accentColor } = config;
|
||||
|
||||
switch (type) {
|
||||
case 'wood':
|
||||
return <WoodFloor color={color} accentColor={accentColor} />;
|
||||
case 'tile':
|
||||
return <TileFloor color={color} accentColor={accentColor} />;
|
||||
case 'carpet':
|
||||
return <CarpetFloor color={color} />;
|
||||
default:
|
||||
return <WoodFloor color={color} accentColor={accentColor} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Wood Floor ───────────────────────────────────────────────────────────────
|
||||
|
||||
function WoodFloor({ color, accentColor }: { color: string; accentColor?: string }) {
|
||||
const patternId = useId();
|
||||
const grainColor = accentColor ?? darkenHex(color, 0.18);
|
||||
const plankGap = darkenHex(color, 0.3);
|
||||
|
||||
// Alternate plank colors for natural variation
|
||||
const plankColors = useMemo(() => [
|
||||
color,
|
||||
lightenHex(color, 0.05),
|
||||
darkenHex(color, 0.04),
|
||||
blendHex(color, grainColor, 0.15),
|
||||
lightenHex(color, 0.03),
|
||||
darkenHex(color, 0.07),
|
||||
], [color, grainColor]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
{/* Base fill */}
|
||||
<div className="absolute inset-0" style={{ backgroundColor: color }} />
|
||||
|
||||
{/* SVG plank pattern for realistic wood */}
|
||||
<svg className="absolute inset-0 w-full h-full" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<pattern
|
||||
id={patternId}
|
||||
width="100%"
|
||||
height="240"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
{/* 6 planks, each 38px tall with 2px gap */}
|
||||
{plankColors.map((pc, i) => (
|
||||
<g key={i}>
|
||||
{/* Plank body */}
|
||||
<rect
|
||||
x="0"
|
||||
y={i * 40}
|
||||
width="100%"
|
||||
height="38"
|
||||
fill={pc}
|
||||
/>
|
||||
{/* Subtle grain lines within plank */}
|
||||
<line
|
||||
x1="0" y1={i * 40 + 12}
|
||||
x2="100%" y2={i * 40 + 13}
|
||||
stroke={grainColor}
|
||||
strokeWidth="0.5"
|
||||
opacity="0.15"
|
||||
/>
|
||||
<line
|
||||
x1="0" y1={i * 40 + 26}
|
||||
x2="100%" y2={i * 40 + 25}
|
||||
stroke={grainColor}
|
||||
strokeWidth="0.3"
|
||||
opacity="0.1"
|
||||
/>
|
||||
{/* Plank gap line */}
|
||||
<rect
|
||||
x="0"
|
||||
y={i * 40 + 38}
|
||||
width="100%"
|
||||
height="2"
|
||||
fill={plankGap}
|
||||
opacity="0.4"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#${CSS.escape(patternId)})`} />
|
||||
</svg>
|
||||
|
||||
{/* Subtle light gradient: lighter near wall, darker in distance */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.08) 0%, transparent 30%, rgba(0,0,0,0.12) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tile Floor ───────────────────────────────────────────────────────────────
|
||||
|
||||
function TileFloor({ color, accentColor }: { color: string; accentColor?: string }) {
|
||||
const groutColor = accentColor ?? darkenHex(color, 0.2);
|
||||
const altTile = lightenHex(color, 0.06);
|
||||
|
||||
// Checkerboard tile pattern via CSS gradients
|
||||
const tilePattern = useMemo(() => {
|
||||
const size = 50; // tile size in px
|
||||
|
||||
return {
|
||||
backgroundImage: [
|
||||
// Checkerboard: conic gradient creates four quadrants
|
||||
`conic-gradient(${altTile} 0.25turn, ${color} 0.25turn 0.5turn, ${altTile} 0.5turn 0.75turn, ${color} 0.75turn)`,
|
||||
].join(', '),
|
||||
backgroundSize: `${size * 2}px ${size * 2}px`,
|
||||
};
|
||||
}, [color, altTile]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute inset-0" style={tilePattern} />
|
||||
|
||||
{/* Grout lines overlay */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: [
|
||||
`repeating-linear-gradient(0deg, ${groutColor} 0px, ${groutColor} 1px, transparent 1px, transparent 50px)`,
|
||||
`repeating-linear-gradient(90deg, ${groutColor} 0px, ${groutColor} 1px, transparent 1px, transparent 50px)`,
|
||||
].join(', '),
|
||||
opacity: 0.25,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Light gradient for depth */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.06) 0%, transparent 40%, rgba(0,0,0,0.10) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Carpet Floor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function CarpetFloor({ color }: { color: string }) {
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ backgroundColor: color }}>
|
||||
{/* Carpet texture: very subtle noise */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.06] mix-blend-multiply"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
|
||||
backgroundSize: '150px 150px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Light gradient for depth */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.05) 0%, transparent 40%, rgba(0,0,0,0.08) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// src/blobbi/rooms/scene/components/RoomCustomizeSheet.tsx
|
||||
|
||||
/**
|
||||
* RoomCustomizeSheet — Lightweight customization UI for the home room POC.
|
||||
*
|
||||
* Opens as a bottom sheet (mobile) with simple controls for:
|
||||
* - Wall type (paint / wallpaper / brick)
|
||||
* - Floor type (wood / tile / carpet)
|
||||
* - Wall color presets
|
||||
* - Floor color presets (with paired accent colors)
|
||||
* - Theme colors toggle
|
||||
* - Reset to default
|
||||
*
|
||||
* Each control triggers an immediate save via patchScene().
|
||||
* This is intentionally minimal — not a full editor.
|
||||
*/
|
||||
|
||||
import { Loader2, RotateCcw, Paintbrush, Palette } from 'lucide-react';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WallType, FloorType } from '../types';
|
||||
import type { RoomScenePatch } from '../hooks/useRoomSceneEditor';
|
||||
|
||||
// ─── Preset Data ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface ColorPreset {
|
||||
label: string;
|
||||
color: string;
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
const WALL_TYPES: { id: WallType; label: string; icon: string }[] = [
|
||||
{ id: 'paint', label: 'Paint', icon: '🖌️' },
|
||||
{ id: 'wallpaper', label: 'Wallpaper', icon: '🎨' },
|
||||
{ id: 'brick', label: 'Brick', icon: '🧱' },
|
||||
];
|
||||
|
||||
const FLOOR_TYPES: { id: FloorType; label: string; icon: string }[] = [
|
||||
{ id: 'wood', label: 'Wood', icon: '🪵' },
|
||||
{ id: 'tile', label: 'Tile', icon: '🔲' },
|
||||
{ id: 'carpet', label: 'Carpet', icon: '🧶' },
|
||||
];
|
||||
|
||||
const WALL_COLORS: ColorPreset[] = [
|
||||
{ label: 'Cream', color: '#f5f0eb' },
|
||||
{ label: 'Snow', color: '#f8f8f8' },
|
||||
{ label: 'Blush', color: '#f5dfe0' },
|
||||
{ label: 'Sage', color: '#dce5d8' },
|
||||
{ label: 'Sky', color: '#d6e4ef' },
|
||||
{ label: 'Lavender', color: '#e2d9ed' },
|
||||
{ label: 'Peach', color: '#f5dfc9' },
|
||||
{ label: 'Charcoal', color: '#3d3d3d' },
|
||||
{ label: 'Navy', color: '#2a3444' },
|
||||
{ label: 'Terracotta', color: '#c4664a' },
|
||||
];
|
||||
|
||||
const FLOOR_COLORS: ColorPreset[] = [
|
||||
{ label: 'Oak', color: '#c4a882', accentColor: '#a08060' },
|
||||
{ label: 'Walnut', color: '#7a5c3e', accentColor: '#5e4530' },
|
||||
{ label: 'Maple', color: '#d4b896', accentColor: '#b89a78' },
|
||||
{ label: 'Cherry', color: '#8b4c3b', accentColor: '#6d3a2c' },
|
||||
{ label: 'Ash', color: '#bfb5a4', accentColor: '#9e9488' },
|
||||
{ label: 'Slate', color: '#6b7280', accentColor: '#4b5563' },
|
||||
{ label: 'Marble', color: '#e5e0d8', accentColor: '#c8c0b4' },
|
||||
{ label: 'Terracotta', color: '#b86b4a', accentColor: '#944f36' },
|
||||
{ label: 'Seafoam', color: '#7ba69e', accentColor: '#5e8880' },
|
||||
{ label: 'Plum', color: '#6b4c6e', accentColor: '#523e54' },
|
||||
];
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RoomCustomizeSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Current raw (unresolved) scene. */
|
||||
currentWallType: WallType;
|
||||
currentWallColor: string;
|
||||
currentFloorType: FloorType;
|
||||
currentFloorColor: string;
|
||||
currentUseThemeColors: boolean;
|
||||
/** Patch callback — triggers save. */
|
||||
onPatch: (patch: RoomScenePatch) => Promise<void>;
|
||||
/** Reset callback — removes customization. */
|
||||
onReset: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function RoomCustomizeSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentWallType,
|
||||
currentWallColor,
|
||||
currentFloorType,
|
||||
currentFloorColor,
|
||||
currentUseThemeColors,
|
||||
onPatch,
|
||||
onReset,
|
||||
isSaving,
|
||||
}: RoomCustomizeSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="bottom" className="max-h-[70vh] rounded-t-2xl px-0">
|
||||
<SheetHeader className="px-5 pb-2">
|
||||
<SheetTitle className="flex items-center gap-2 text-base">
|
||||
<Paintbrush className="size-4" />
|
||||
Customize Room
|
||||
{isSaving && <Loader2 className="size-3.5 animate-spin text-muted-foreground" />}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-xs text-muted-foreground">
|
||||
Changes save automatically
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="h-full max-h-[calc(70vh-5rem)]">
|
||||
<div className="space-y-5 px-5 pb-8">
|
||||
{/* ── Theme Colors Toggle ── */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="size-4 text-muted-foreground" />
|
||||
<Label htmlFor="theme-toggle" className="text-sm font-medium">
|
||||
Use theme colors
|
||||
</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="theme-toggle"
|
||||
checked={currentUseThemeColors}
|
||||
onCheckedChange={(checked) => onPatch({ useThemeColors: checked })}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Wall Type ── */}
|
||||
<div className="space-y-2.5">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Wall</h4>
|
||||
<div className="flex gap-2">
|
||||
{WALL_TYPES.map((wt) => (
|
||||
<button
|
||||
key={wt.id}
|
||||
onClick={() => onPatch({ wall: { type: wt.id } })}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'flex-1 flex flex-col items-center gap-1 py-2.5 px-2 rounded-xl text-xs font-medium transition-all',
|
||||
'border-2',
|
||||
currentWallType === wt.id
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-transparent bg-muted/50 text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">{wt.icon}</span>
|
||||
<span>{wt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Wall color swatches */}
|
||||
{!currentUseThemeColors && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{WALL_COLORS.map((preset) => (
|
||||
<button
|
||||
key={preset.color}
|
||||
onClick={() => onPatch({
|
||||
wall: {
|
||||
color: preset.color,
|
||||
...(preset.accentColor ? { accentColor: preset.accentColor } : {}),
|
||||
},
|
||||
})}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'size-8 rounded-full border-2 transition-all hover:scale-110 active:scale-95',
|
||||
'shadow-sm',
|
||||
currentWallColor === preset.color
|
||||
? 'border-primary ring-2 ring-primary/30 scale-110'
|
||||
: 'border-border/50',
|
||||
)}
|
||||
style={{ backgroundColor: preset.color }}
|
||||
title={preset.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Floor Type ── */}
|
||||
<div className="space-y-2.5">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Floor</h4>
|
||||
<div className="flex gap-2">
|
||||
{FLOOR_TYPES.map((ft) => (
|
||||
<button
|
||||
key={ft.id}
|
||||
onClick={() => onPatch({ floor: { type: ft.id } })}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'flex-1 flex flex-col items-center gap-1 py-2.5 px-2 rounded-xl text-xs font-medium transition-all',
|
||||
'border-2',
|
||||
currentFloorType === ft.id
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-transparent bg-muted/50 text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">{ft.icon}</span>
|
||||
<span>{ft.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Floor color swatches */}
|
||||
{!currentUseThemeColors && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{FLOOR_COLORS.map((preset) => (
|
||||
<button
|
||||
key={preset.color}
|
||||
onClick={() => onPatch({
|
||||
floor: {
|
||||
color: preset.color,
|
||||
...(preset.accentColor ? { accentColor: preset.accentColor } : {}),
|
||||
},
|
||||
})}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'size-8 rounded-full border-2 transition-all hover:scale-110 active:scale-95',
|
||||
'shadow-sm',
|
||||
currentFloorColor === preset.color
|
||||
? 'border-primary ring-2 ring-primary/30 scale-110'
|
||||
: 'border-border/50',
|
||||
)}
|
||||
style={{ backgroundColor: preset.color }}
|
||||
title={preset.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Reset ── */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
disabled={isSaving}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Reset to Default
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// src/blobbi/rooms/scene/components/RoomSceneLayer.tsx
|
||||
|
||||
/**
|
||||
* RoomSceneLayer — The composite room background behind Blobbi.
|
||||
*
|
||||
* Renders as an absolutely-positioned layer that fills its parent entirely.
|
||||
* Must be placed inside a container with `position: relative`.
|
||||
*
|
||||
* Visual structure (top to bottom):
|
||||
* ┌──────────────────────────┐
|
||||
* │ │ Wall (~62% of height)
|
||||
* │ WallLayer │ Flat, front-facing
|
||||
* │ │
|
||||
* ├──────────────────────────┤ Baseboard shadow
|
||||
* │ ╲ ╱ │
|
||||
* │ ╲ FloorLayer ╱ │ Floor (~38% of height)
|
||||
* │ ╲ ╱ │ CSS 3D perspective transform
|
||||
* └──────────────────────────┘
|
||||
*
|
||||
* The floor uses CSS `perspective` + `rotateX` with `transform-origin: top center`
|
||||
* to create depth. The top edge of the floor stays at the wall-floor junction
|
||||
* while the surface recedes into the distance, creating a natural room feel.
|
||||
*
|
||||
* The baseboard is a subtle shadow gradient at the junction line.
|
||||
*
|
||||
* A soft vignette around the edges adds subtle depth framing.
|
||||
*/
|
||||
|
||||
import type { ResolvedRoomScene } from '../types';
|
||||
import { WallLayer } from './WallLayer';
|
||||
import { FloorLayer } from './FloorLayer';
|
||||
|
||||
interface RoomSceneLayerProps {
|
||||
scene: ResolvedRoomScene;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall/floor split — 60% wall, 40% floor.
|
||||
*
|
||||
* A slightly generous floor area gives enough room for the perspective
|
||||
* transform to read as real depth without the floor feeling squished.
|
||||
* The 60/40 ratio works well across both desktop and mobile viewports.
|
||||
*/
|
||||
export const WALL_PERCENT = 60;
|
||||
const FLOOR_PERCENT = 100 - WALL_PERCENT; // 40%
|
||||
|
||||
/**
|
||||
* Floor perspective settings.
|
||||
*
|
||||
* - `perspective: 600px` — gentle distance; avoids extreme distortion on
|
||||
* mobile while still producing visible foreshortening on desktop.
|
||||
* - `rotateX(22deg)` — moderate tilt; enough to read as "floor receding"
|
||||
* without fighting the Blobbi hero or bottom bar visually.
|
||||
* - `height: 160%` — overflow factor to cover the gap that forms at the
|
||||
* bottom edge when the surface is foreshortened by the perspective.
|
||||
* 160% at 22deg is sufficient (cos(22deg) ~ 0.93).
|
||||
*/
|
||||
export const FLOOR_PERSPECTIVE = '600px';
|
||||
export const FLOOR_TILT = 'rotateX(22deg)';
|
||||
export const FLOOR_OVERFLOW = '160%';
|
||||
|
||||
export function RoomSceneLayer({ scene }: RoomSceneLayerProps) {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden pointer-events-none select-none"
|
||||
aria-hidden="true"
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
{/* ── Wall Area ── */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-0"
|
||||
style={{ height: `${WALL_PERCENT}%` }}
|
||||
>
|
||||
<WallLayer config={scene.wall} />
|
||||
</div>
|
||||
|
||||
{/* ── Baseboard / Junction Shadow ── */}
|
||||
<div
|
||||
className="absolute inset-x-0"
|
||||
style={{
|
||||
top: `calc(${WALL_PERCENT}% - 8px)`,
|
||||
height: '16px',
|
||||
background: 'linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.06) 30%, rgba(0,0,0,0.10) 50%, rgba(0,0,0,0.06) 70%, transparent 100%)',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Floor Area with Perspective ── */}
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0"
|
||||
style={{
|
||||
top: `${WALL_PERCENT}%`,
|
||||
height: `${FLOOR_PERCENT}%`,
|
||||
// Perspective container: the vanishing point is at the center
|
||||
// of the wall-floor junction line.
|
||||
perspective: FLOOR_PERSPECTIVE,
|
||||
perspectiveOrigin: '50% 0%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
// Tilt the floor plane backward to create depth.
|
||||
// transform-origin at top center keeps the junction line fixed.
|
||||
transformOrigin: 'top center',
|
||||
transform: FLOOR_TILT,
|
||||
// Extend taller to cover any gaps from the perspective
|
||||
// foreshortening at the bottom edge.
|
||||
height: FLOOR_OVERFLOW,
|
||||
}}
|
||||
>
|
||||
<FloorLayer config={scene.floor} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Soft Vignette ── */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 90% 75% at 50% 45%, transparent 50%, rgba(0,0,0,0.05) 100%)',
|
||||
zIndex: 3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// src/blobbi/rooms/scene/components/WallLayer.tsx
|
||||
|
||||
/**
|
||||
* WallLayer — Renders the wall surface behind Blobbi.
|
||||
*
|
||||
* The wall is always front-facing (no perspective transform). Different
|
||||
* wall types produce different visual textures on top of the base color:
|
||||
*
|
||||
* - paint: Solid color with a subtle depth gradient
|
||||
* - wallpaper: Repeating pattern overlay (diamond/dots)
|
||||
* - brick: Brick masonry pattern via CSS gradients
|
||||
*
|
||||
* The component fills its parent container entirely.
|
||||
*/
|
||||
|
||||
import { useMemo, useId } from 'react';
|
||||
import { darkenHex, lightenHex } from '@/lib/colorUtils';
|
||||
import type { WallConfig } from '../types';
|
||||
|
||||
interface WallLayerProps {
|
||||
config: WallConfig;
|
||||
}
|
||||
|
||||
export function WallLayer({ config }: WallLayerProps) {
|
||||
const { type, color, accentColor } = config;
|
||||
|
||||
switch (type) {
|
||||
case 'paint':
|
||||
return <PaintWall color={color} />;
|
||||
case 'wallpaper':
|
||||
return <WallpaperWall color={color} accentColor={accentColor} />;
|
||||
case 'brick':
|
||||
return <BrickWall color={color} accentColor={accentColor} />;
|
||||
default:
|
||||
return <PaintWall color={color} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Paint Wall ───────────────────────────────────────────────────────────────
|
||||
|
||||
function PaintWall({ color }: { color: string }) {
|
||||
// Subtle gradient from slightly lighter at top to slightly darker at bottom
|
||||
// simulates the natural light fall-off in a room.
|
||||
const topColor = lightenHex(color, 0.04);
|
||||
const bottomColor = darkenHex(color, 0.06);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `linear-gradient(180deg, ${topColor} 0%, ${color} 40%, ${bottomColor} 100%)`,
|
||||
}}
|
||||
/>
|
||||
{/* Very subtle noise texture for a painted-surface feel */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03] mix-blend-multiply"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
|
||||
backgroundSize: '200px 200px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Wallpaper Wall ───────────────────────────────────────────────────────────
|
||||
|
||||
function WallpaperWall({ color, accentColor }: { color: string; accentColor?: string }) {
|
||||
const patternId = useId();
|
||||
const patternColor = accentColor ?? darkenHex(color, 0.15);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ backgroundColor: color }}>
|
||||
{/* SVG diamond trellis pattern */}
|
||||
<svg className="absolute inset-0 w-full h-full" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<pattern
|
||||
id={patternId}
|
||||
width="24"
|
||||
height="24"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
{/* Small diamond at center */}
|
||||
<path
|
||||
d="M12 2 L22 12 L12 22 L2 12 Z"
|
||||
fill="none"
|
||||
stroke={patternColor}
|
||||
strokeWidth="0.6"
|
||||
opacity="0.15"
|
||||
/>
|
||||
{/* Tiny dot at intersections */}
|
||||
<circle cx="12" cy="12" r="1" fill={patternColor} opacity="0.1" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#${CSS.escape(patternId)})`} />
|
||||
</svg>
|
||||
{/* Same subtle depth gradient as paint */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.03) 0%, transparent 40%, rgba(0,0,0,0.05) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Brick Wall ───────────────────────────────────────────────────────────────
|
||||
|
||||
function BrickWall({ color, accentColor }: { color: string; accentColor?: string }) {
|
||||
const mortarColor = accentColor ?? darkenHex(color, 0.25);
|
||||
|
||||
// CSS-only brick pattern using repeating-linear-gradient
|
||||
// Creates the characteristic offset-row masonry look.
|
||||
const brickPattern = useMemo(() => {
|
||||
const brickH = 20; // brick height in px
|
||||
const mortarW = 2; // mortar line width
|
||||
const brickW = 50; // brick width in px
|
||||
|
||||
return {
|
||||
backgroundImage: [
|
||||
// Horizontal mortar lines
|
||||
`repeating-linear-gradient(
|
||||
180deg,
|
||||
${mortarColor} 0px,
|
||||
${mortarColor} ${mortarW}px,
|
||||
transparent ${mortarW}px,
|
||||
transparent ${brickH + mortarW}px
|
||||
)`,
|
||||
// Vertical mortar lines (even rows)
|
||||
`repeating-linear-gradient(
|
||||
90deg,
|
||||
${mortarColor} 0px,
|
||||
${mortarColor} ${mortarW}px,
|
||||
transparent ${mortarW}px,
|
||||
transparent ${brickW + mortarW}px
|
||||
)`,
|
||||
].join(', '),
|
||||
backgroundSize: `${brickW + mortarW}px ${(brickH + mortarW) * 2}px`,
|
||||
// Offset odd rows by half a brick width
|
||||
backgroundPosition: `0 0, ${(brickW + mortarW) / 2}px ${brickH + mortarW}px`,
|
||||
};
|
||||
}, [mortarColor]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ backgroundColor: color }}>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={brickPattern}
|
||||
/>
|
||||
{/* Subtle depth gradient */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.02) 0%, transparent 50%, rgba(0,0,0,0.08) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// src/blobbi/rooms/scene/defaults.ts
|
||||
|
||||
/**
|
||||
* ⚠️ LEGACY defaults — superseded by `house-defaults.ts`.
|
||||
*
|
||||
* The canonical default scenes for ALL rooms are now defined in
|
||||
* `src/blobbi/house/lib/house-defaults.ts` (used by kind 11127).
|
||||
*
|
||||
* This file is retained for:
|
||||
* - `DEFAULT_HOME_SCENE`: still used as an ultimate fallback in
|
||||
* `useRoomScene` and `useRoomSceneEditor` when a room has no
|
||||
* house data AND no house-level default (should never happen
|
||||
* for known rooms, but provides safety).
|
||||
* - `DEFAULT_ROOM_SCENES` / `getDefaultScene`: exported for
|
||||
* backward compatibility but no longer the source of truth.
|
||||
*
|
||||
* Prefer importing from `@/blobbi/house` for new code.
|
||||
*/
|
||||
|
||||
import type { BlobbiRoomId } from '../lib/room-config';
|
||||
import type { RoomScene } from './types';
|
||||
|
||||
// ─── Home Room Default (ultimate fallback) ────────────────────────────────────
|
||||
|
||||
export const DEFAULT_HOME_SCENE: RoomScene = {
|
||||
useThemeColors: false,
|
||||
wall: {
|
||||
type: 'paint',
|
||||
color: '#f5f0eb', // warm cream
|
||||
},
|
||||
floor: {
|
||||
type: 'wood',
|
||||
color: '#c4a882', // warm medium wood
|
||||
accentColor: '#a08060', // darker wood grain
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Legacy Default Scene Registry ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @deprecated Use `getDefaultRoomScene()` from `@/blobbi/house/lib/house-defaults`
|
||||
* for the canonical defaults. This map only contains `home` and is kept for
|
||||
* backward compatibility.
|
||||
*/
|
||||
export const DEFAULT_ROOM_SCENES: Partial<Record<BlobbiRoomId, RoomScene>> = {
|
||||
home: DEFAULT_HOME_SCENE,
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use `getDefaultRoomScene()` from `@/blobbi/house/lib/house-defaults`.
|
||||
*/
|
||||
export function getDefaultScene(roomId: BlobbiRoomId): RoomScene | undefined {
|
||||
return DEFAULT_ROOM_SCENES[roomId];
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// src/blobbi/rooms/scene/hooks/useRoomScene.ts
|
||||
|
||||
/**
|
||||
* useRoomScene — Hook that resolves the active room scene for a given room.
|
||||
*
|
||||
* Data flow (post-migration to kind 11127):
|
||||
* 1. Read room scene from the house event content (kind 11127)
|
||||
* 2. Fall back to default scene if room not found
|
||||
* 3. If `useThemeColors` is true, resolve colors from the active app theme
|
||||
* 4. Return the fully resolved scene, ready for rendering
|
||||
*
|
||||
* The hook is memoized to avoid unnecessary re-renders. It only recomputes
|
||||
* when the house content, room ID, or theme config changes.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getRoomSceneFromHouse } from '@/blobbi/house';
|
||||
import { getDefaultRoomScene } from '@/blobbi/house/lib/house-defaults';
|
||||
import type { ResolvedRoomScene, RoomScene } from '../types';
|
||||
import { DEFAULT_HOME_SCENE } from '../defaults';
|
||||
import { getActiveThemeColors, resolveRoomScene } from '../resolver';
|
||||
|
||||
/**
|
||||
* Resolve the active room scene for a given room.
|
||||
*
|
||||
* @param roomId - The room to get the scene for
|
||||
* @param houseContent - The raw kind 11127 house event content string (or empty)
|
||||
* @returns The fully resolved scene with concrete colors
|
||||
*/
|
||||
export function useRoomScene(
|
||||
roomId: string,
|
||||
houseContent: string,
|
||||
): ResolvedRoomScene {
|
||||
const { config } = useAppContext();
|
||||
|
||||
// Get the scene for this room from house content → default → ultimate fallback
|
||||
const scene = useMemo((): RoomScene => {
|
||||
const fromHouse = getRoomSceneFromHouse(houseContent, roomId);
|
||||
if (fromHouse) return fromHouse;
|
||||
const defaultScene = getDefaultRoomScene(roomId);
|
||||
if (defaultScene) return defaultScene;
|
||||
return DEFAULT_HOME_SCENE;
|
||||
}, [houseContent, roomId]);
|
||||
|
||||
// Get current theme colors for potential theme-based resolution
|
||||
const themeColors = useMemo(
|
||||
() => getActiveThemeColors(config),
|
||||
// Only the fields that affect color resolution
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[config.theme, config.customTheme?.colors, config.themes],
|
||||
);
|
||||
|
||||
// Resolve final colors (applies theme if enabled)
|
||||
const resolved = useMemo(
|
||||
() => resolveRoomScene(scene, themeColors),
|
||||
[scene, themeColors],
|
||||
);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// src/blobbi/rooms/scene/hooks/useRoomSceneEditor.ts
|
||||
|
||||
/**
|
||||
* useRoomSceneEditor — Hook for editing and persisting room scene customization.
|
||||
*
|
||||
* Provides:
|
||||
* - The current raw (unresolved) scene for the room
|
||||
* - A `patch` function for partial, field-level updates
|
||||
* - A `reset` function to remove customization (back to defaults)
|
||||
* - `isSaving` state for UI feedback
|
||||
*
|
||||
* Persistence target (post-migration):
|
||||
* - Reads and writes to kind 11127 (Blobbi House root event)
|
||||
* - Uses fetchFreshEvent for safe read-modify-write
|
||||
* - Uses patchHouseRoomScene for field-level partial updates
|
||||
* - All sibling rooms, items, and unknown keys are preserved
|
||||
* - Optimistic cache update via updateHouseEvent
|
||||
*
|
||||
* This hook is designed for the customization UI only (not for read-only rendering).
|
||||
* For rendering, use `useRoomScene` instead.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import {
|
||||
KIND_BLOBBI_HOUSE,
|
||||
buildHouseDTag,
|
||||
buildHouseTags,
|
||||
} from '@/blobbi/house/lib/house-constants';
|
||||
import {
|
||||
getRoomSceneFromHouse,
|
||||
patchHouseRoomScene,
|
||||
resetHouseRoomScene,
|
||||
} from '@/blobbi/house/lib/house-content';
|
||||
import { getDefaultRoomScene } from '@/blobbi/house/lib/house-defaults';
|
||||
import type { HouseRoomScene } from '@/blobbi/house/lib/house-types';
|
||||
import type { WallConfig, FloorConfig, RoomScene } from '../types';
|
||||
import { DEFAULT_HOME_SCENE } from '../defaults';
|
||||
|
||||
/** Partial update shape accepted by the patch function. */
|
||||
export interface RoomScenePatch {
|
||||
useThemeColors?: boolean;
|
||||
wall?: Partial<WallConfig>;
|
||||
floor?: Partial<FloorConfig>;
|
||||
}
|
||||
|
||||
interface UseRoomSceneEditorResult {
|
||||
/** The current raw (unresolved) scene for this room. */
|
||||
scene: RoomScene;
|
||||
/** Apply a partial update to the room scene. Persists to kind 11127. */
|
||||
patchScene: (patch: RoomScenePatch) => Promise<void>;
|
||||
/** Reset the room to its default scene. Persists to kind 11127. */
|
||||
resetScene: () => Promise<void>;
|
||||
/** Whether a save operation is currently in flight. */
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function useRoomSceneEditor(
|
||||
roomId: string,
|
||||
houseEvent: NostrEvent | null,
|
||||
updateHouseEvent: (event: NostrEvent) => void,
|
||||
): UseRoomSceneEditorResult {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// The fallback scene for this room
|
||||
const fallbackScene = useMemo(
|
||||
(): HouseRoomScene => getDefaultRoomScene(roomId) ?? DEFAULT_HOME_SCENE,
|
||||
[roomId],
|
||||
);
|
||||
|
||||
// Parse the current raw scene from house content
|
||||
const scene = useMemo((): RoomScene => {
|
||||
if (!houseEvent?.content) return fallbackScene;
|
||||
return getRoomSceneFromHouse(houseEvent.content, roomId) ?? fallbackScene;
|
||||
}, [houseEvent?.content, roomId, fallbackScene]);
|
||||
|
||||
// ── Patch Scene ──
|
||||
const patchScene = useCallback(async (patch: RoomScenePatch) => {
|
||||
if (!user?.pubkey) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Fetch fresh house event for safe read-modify-write
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [KIND_BLOBBI_HOUSE],
|
||||
authors: [user.pubkey],
|
||||
'#d': [buildHouseDTag(user.pubkey)],
|
||||
});
|
||||
|
||||
const existingContent = prev?.content ?? houseEvent?.content ?? '';
|
||||
const existingTags = prev?.tags ?? buildHouseTags(user.pubkey);
|
||||
|
||||
// Apply the partial patch to house content
|
||||
const updatedContent = patchHouseRoomScene(
|
||||
existingContent,
|
||||
roomId,
|
||||
patch,
|
||||
fallbackScene,
|
||||
);
|
||||
|
||||
// Publish to kind 11127
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_HOUSE,
|
||||
content: updatedContent,
|
||||
tags: existingTags,
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
// Optimistic cache update
|
||||
updateHouseEvent(event);
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[useRoomSceneEditor] Failed to save room scene:', err);
|
||||
}
|
||||
toast({
|
||||
title: 'Failed to save',
|
||||
description: 'Room customization could not be saved. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [user?.pubkey, nostr, publishEvent, updateHouseEvent, roomId, fallbackScene, houseEvent?.content]);
|
||||
|
||||
// ── Reset Scene ──
|
||||
const resetScene = useCallback(async () => {
|
||||
if (!user?.pubkey) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [KIND_BLOBBI_HOUSE],
|
||||
authors: [user.pubkey],
|
||||
'#d': [buildHouseDTag(user.pubkey)],
|
||||
});
|
||||
|
||||
const existingContent = prev?.content ?? houseEvent?.content ?? '';
|
||||
const existingTags = prev?.tags ?? buildHouseTags(user.pubkey);
|
||||
|
||||
// Reset this room's scene in house content
|
||||
const updatedContent = resetHouseRoomScene(existingContent, roomId);
|
||||
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_HOUSE,
|
||||
content: updatedContent,
|
||||
tags: existingTags,
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
updateHouseEvent(event);
|
||||
|
||||
toast({
|
||||
title: 'Room reset',
|
||||
description: 'Room returned to default appearance.',
|
||||
});
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[useRoomSceneEditor] Failed to reset room scene:', err);
|
||||
}
|
||||
toast({
|
||||
title: 'Failed to reset',
|
||||
description: 'Could not reset room. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [user?.pubkey, nostr, publishEvent, updateHouseEvent, roomId, houseEvent?.content]);
|
||||
|
||||
return { scene, patchScene, resetScene, isSaving };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// src/blobbi/rooms/scene/index.ts — barrel export
|
||||
|
||||
// ── Types ──
|
||||
export type {
|
||||
WallType,
|
||||
FloorType,
|
||||
WallConfig,
|
||||
FloorConfig,
|
||||
RoomScene,
|
||||
ResolvedRoomScene,
|
||||
RoomCustomizationMap,
|
||||
} from './types';
|
||||
|
||||
// ── Defaults ──
|
||||
export { DEFAULT_HOME_SCENE, DEFAULT_ROOM_SCENES, getDefaultScene } from './defaults';
|
||||
|
||||
// ── Resolver ──
|
||||
export { resolveRoomScene, getActiveThemeColors } from './resolver';
|
||||
|
||||
// ── Legacy Persistence (kind 11125) ──
|
||||
// ⚠️ These helpers are for reading legacy `roomCustomization` data only.
|
||||
// New code should use house content helpers from `@/blobbi/house`.
|
||||
export {
|
||||
parseRoomCustomization,
|
||||
updateRoomSceneContent,
|
||||
patchRoomSceneContent,
|
||||
removeRoomSceneContent,
|
||||
} from './lib/room-scene-content';
|
||||
|
||||
// ── Hooks ──
|
||||
export { useRoomScene } from './hooks/useRoomScene';
|
||||
export { useRoomSceneEditor, type RoomScenePatch } from './hooks/useRoomSceneEditor';
|
||||
|
||||
// ── Layout Constants ──
|
||||
export {
|
||||
WALL_PERCENT,
|
||||
FLOOR_PERSPECTIVE,
|
||||
FLOOR_TILT,
|
||||
FLOOR_OVERFLOW,
|
||||
} from './components/RoomSceneLayer';
|
||||
|
||||
// ── Components ──
|
||||
export { RoomSceneLayer } from './components/RoomSceneLayer';
|
||||
export { WallLayer } from './components/WallLayer';
|
||||
export { FloorLayer } from './components/FloorLayer';
|
||||
export { RoomCustomizeSheet } from './components/RoomCustomizeSheet';
|
||||
@@ -0,0 +1,259 @@
|
||||
// src/blobbi/rooms/scene/lib/room-scene-content.ts
|
||||
|
||||
/**
|
||||
* ⚠️ LEGACY — Room Scene Persistence for kind 11125.
|
||||
*
|
||||
* Room scenes have been migrated to kind 11127 (Blobbi House).
|
||||
* These helpers are retained ONLY for:
|
||||
* 1. Reading legacy `roomCustomization` data during migration
|
||||
* (see `house-migration.ts`)
|
||||
* 2. Backward compatibility if any legacy consumers still exist
|
||||
*
|
||||
* NEW CODE should use the house content helpers in
|
||||
* `src/blobbi/house/lib/house-content.ts` instead.
|
||||
*
|
||||
* ── Original Purpose ─────────────────────────────────────────────────
|
||||
*
|
||||
* Read/write helpers for the `roomCustomization` section inside
|
||||
* kind 11125 content JSON.
|
||||
*
|
||||
* ── Persisted Shape (legacy) ─────────────────────────────────────────
|
||||
*
|
||||
* {
|
||||
* "roomCustomization": {
|
||||
* "home": {
|
||||
* "useThemeColors": false,
|
||||
* "wall": { "type": "paint", "color": "#f5f0eb" },
|
||||
* "floor": { "type": "wood", "color": "#c4a882", "accentColor": "#a08060" }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
import { safeParseContent, updateContentSection } from '@/blobbi/core/lib/content-json';
|
||||
import type { RoomScene, WallConfig, FloorConfig, RoomCustomizationMap } from '../types';
|
||||
|
||||
// ─── Validation Constants ─────────────────────────────────────────────────────
|
||||
|
||||
const VALID_WALL_TYPES = new Set(['paint', 'wallpaper', 'brick']);
|
||||
const VALID_FLOOR_TYPES = new Set(['wood', 'tile', 'carpet']);
|
||||
const HEX_COLOR_RE = /^#[0-9a-fA-F]{3,8}$/;
|
||||
|
||||
// ─── Validation Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function isHexColor(v: unknown): v is string {
|
||||
return typeof v === 'string' && HEX_COLOR_RE.test(v);
|
||||
}
|
||||
|
||||
function validateWallConfig(raw: unknown): WallConfig | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
if (typeof obj.type !== 'string' || !VALID_WALL_TYPES.has(obj.type)) return null;
|
||||
if (!isHexColor(obj.color)) return null;
|
||||
|
||||
return {
|
||||
type: obj.type as WallConfig['type'],
|
||||
color: obj.color,
|
||||
...(isHexColor(obj.accentColor) ? { accentColor: obj.accentColor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateFloorConfig(raw: unknown): FloorConfig | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
if (typeof obj.type !== 'string' || !VALID_FLOOR_TYPES.has(obj.type)) return null;
|
||||
if (!isHexColor(obj.color)) return null;
|
||||
|
||||
return {
|
||||
type: obj.type as FloorConfig['type'],
|
||||
color: obj.color,
|
||||
...(isHexColor(obj.accentColor) ? { accentColor: obj.accentColor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateRoomScene(raw: unknown): RoomScene | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
const wall = validateWallConfig(obj.wall);
|
||||
const floor = validateFloorConfig(obj.floor);
|
||||
if (!wall || !floor) return null;
|
||||
|
||||
return {
|
||||
useThemeColors: obj.useThemeColors === true,
|
||||
wall,
|
||||
floor,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Reading ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse the `roomCustomization` section from kind 11125 content.
|
||||
*
|
||||
* Returns a validated map of room ID → RoomScene, or undefined
|
||||
* if the section is missing or entirely invalid. Individual rooms
|
||||
* with invalid data are silently dropped (not propagated).
|
||||
*/
|
||||
export function parseRoomCustomization(content: string): RoomCustomizationMap | undefined {
|
||||
const { data } = safeParseContent(content);
|
||||
const rc = data.roomCustomization;
|
||||
|
||||
if (!rc || typeof rc !== 'object' || Array.isArray(rc)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: RoomCustomizationMap = {};
|
||||
let hasEntries = false;
|
||||
|
||||
for (const [roomId, raw] of Object.entries(rc as Record<string, unknown>)) {
|
||||
const validated = validateRoomScene(raw);
|
||||
if (validated) {
|
||||
// Cast is safe: we only persist valid BlobbiRoomId keys, but we
|
||||
// also tolerate unknown room IDs gracefully (they're just ignored
|
||||
// during rendering but preserved during write-back).
|
||||
result[roomId as keyof RoomCustomizationMap] = validated;
|
||||
hasEntries = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasEntries ? result : undefined;
|
||||
}
|
||||
|
||||
// ─── Writing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update a single room's scene in the `roomCustomization` content section.
|
||||
*
|
||||
* Safety guarantees:
|
||||
* 1. All other top-level content sections are preserved (dailyMissions,
|
||||
* progression, unknown keys)
|
||||
* 2. Other rooms within `roomCustomization` are preserved
|
||||
* 3. Only the specified room's scene is updated
|
||||
*
|
||||
* @param existingContent - The current `event.content` string (may be empty)
|
||||
* @param roomId - The room to update
|
||||
* @param scene - The new scene for that room
|
||||
* @returns The serialized content string with the room's scene updated
|
||||
*/
|
||||
export function updateRoomSceneContent(
|
||||
existingContent: string,
|
||||
roomId: string,
|
||||
scene: RoomScene,
|
||||
): string {
|
||||
const { data } = safeParseContent(existingContent);
|
||||
|
||||
// Get existing roomCustomization map, or start fresh
|
||||
const existingMap = (
|
||||
data.roomCustomization &&
|
||||
typeof data.roomCustomization === 'object' &&
|
||||
!Array.isArray(data.roomCustomization)
|
||||
)
|
||||
? { ...(data.roomCustomization as Record<string, unknown>) }
|
||||
: {};
|
||||
|
||||
// Update only the targeted room
|
||||
existingMap[roomId] = scene;
|
||||
|
||||
// Write back via the standard section updater (preserves all sibling sections)
|
||||
return updateContentSection(existingContent, 'roomCustomization', existingMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Partially update a single room's scene in the `roomCustomization` content section.
|
||||
*
|
||||
* Unlike `updateRoomSceneContent` which replaces the entire room scene,
|
||||
* this function deep-merges a partial update into the existing scene.
|
||||
*
|
||||
* Safety guarantees:
|
||||
* 1. All other top-level content sections are preserved
|
||||
* 2. Other rooms within `roomCustomization` are preserved
|
||||
* 3. Only the specified fields within the room scene are changed
|
||||
* 4. Unchanged fields (wall, floor, useThemeColors) remain intact
|
||||
* 5. Within wall/floor, unchanged sub-fields are preserved
|
||||
*
|
||||
* @param existingContent - The current `event.content` string (may be empty)
|
||||
* @param roomId - The room to update
|
||||
* @param patch - Partial scene update (only changed fields)
|
||||
* @param fallbackScene - Scene to use if the room has no existing config
|
||||
* @returns The serialized content string with the room's scene patched
|
||||
*/
|
||||
export function patchRoomSceneContent(
|
||||
existingContent: string,
|
||||
roomId: string,
|
||||
patch: Partial<{
|
||||
useThemeColors: boolean;
|
||||
wall: Partial<WallConfig>;
|
||||
floor: Partial<FloorConfig>;
|
||||
}>,
|
||||
fallbackScene: RoomScene,
|
||||
): string {
|
||||
const { data } = safeParseContent(existingContent);
|
||||
|
||||
// Get existing roomCustomization map, or start fresh
|
||||
const existingMap = (
|
||||
data.roomCustomization &&
|
||||
typeof data.roomCustomization === 'object' &&
|
||||
!Array.isArray(data.roomCustomization)
|
||||
)
|
||||
? { ...(data.roomCustomization as Record<string, unknown>) }
|
||||
: {};
|
||||
|
||||
// Get the existing scene for this room, or use the fallback
|
||||
const existingRoomRaw = existingMap[roomId];
|
||||
const existingRoom = validateRoomScene(existingRoomRaw) ?? fallbackScene;
|
||||
|
||||
// Deep-merge the patch into the existing scene
|
||||
const merged: RoomScene = {
|
||||
useThemeColors: patch.useThemeColors ?? existingRoom.useThemeColors,
|
||||
wall: {
|
||||
...existingRoom.wall,
|
||||
...(patch.wall ?? {}),
|
||||
} as WallConfig,
|
||||
floor: {
|
||||
...existingRoom.floor,
|
||||
...(patch.floor ?? {}),
|
||||
} as FloorConfig,
|
||||
};
|
||||
|
||||
existingMap[roomId] = merged;
|
||||
return updateContentSection(existingContent, 'roomCustomization', existingMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a room's scene from the `roomCustomization` content section.
|
||||
*
|
||||
* Used when resetting a room back to its default scene.
|
||||
* If this was the last room, the `roomCustomization` key is removed entirely.
|
||||
*
|
||||
* @param existingContent - The current `event.content` string
|
||||
* @param roomId - The room to remove
|
||||
* @returns The serialized content string
|
||||
*/
|
||||
export function removeRoomSceneContent(
|
||||
existingContent: string,
|
||||
roomId: string,
|
||||
): string {
|
||||
const { data } = safeParseContent(existingContent);
|
||||
|
||||
if (
|
||||
!data.roomCustomization ||
|
||||
typeof data.roomCustomization !== 'object' ||
|
||||
Array.isArray(data.roomCustomization)
|
||||
) {
|
||||
return existingContent; // Nothing to remove
|
||||
}
|
||||
|
||||
const existingMap = { ...(data.roomCustomization as Record<string, unknown>) };
|
||||
delete existingMap[roomId];
|
||||
|
||||
// If map is now empty, remove the section entirely
|
||||
if (Object.keys(existingMap).length === 0) {
|
||||
const { roomCustomization: _, ...rest } = data;
|
||||
return JSON.stringify(rest);
|
||||
}
|
||||
|
||||
return updateContentSection(existingContent, 'roomCustomization', existingMap);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// src/blobbi/rooms/scene/resolver.ts
|
||||
|
||||
/**
|
||||
* Room Scene Resolver — Applies optional theme-based colors to a scene.
|
||||
*
|
||||
* The resolver takes a declarative RoomScene and the current theme's
|
||||
* core colors, and produces a ResolvedRoomScene with final concrete colors.
|
||||
*
|
||||
* ── Theme as Palette Input ────────────────────────────────────────────────
|
||||
*
|
||||
* The theme does NOT replace the room scene. It only influences the
|
||||
* color palette when `scene.useThemeColors` is true:
|
||||
*
|
||||
* - Wall/floor *types* always come from the scene declaration
|
||||
* - Only the *colors* are derived from the theme
|
||||
* - If theme colors are unavailable, falls back to scene-local colors
|
||||
*
|
||||
* Color derivation strategy:
|
||||
* - Wall color: derived from the theme's background color (warmed slightly)
|
||||
* - Floor color: derived from the theme's primary color (earthy/muted version)
|
||||
* - Floor accent: a darker shade of the floor color
|
||||
*/
|
||||
|
||||
import type { CoreThemeColors } from '@/themes';
|
||||
import type { AppConfig, Theme } from '@/contexts/AppContext';
|
||||
import { builtinThemes, resolveTheme, resolveThemeConfig } from '@/themes';
|
||||
import {
|
||||
parseHsl,
|
||||
hslToRgb,
|
||||
rgbToHex,
|
||||
darkenHex,
|
||||
formatHsl,
|
||||
} from '@/lib/colorUtils';
|
||||
import type { RoomScene, ResolvedRoomScene } from './types';
|
||||
|
||||
// ─── Theme Color Extraction ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the currently active CoreThemeColors from the app config.
|
||||
*
|
||||
* Resolves through the full theme chain:
|
||||
* system → light/dark OS preference
|
||||
* custom → user's custom theme colors
|
||||
* light/dark → builtin or configured theme colors
|
||||
*/
|
||||
export function getActiveThemeColors(config: AppConfig): CoreThemeColors {
|
||||
const resolved: 'light' | 'dark' | 'custom' = resolveTheme(config.theme as Theme);
|
||||
|
||||
if (resolved === 'custom') {
|
||||
return config.customTheme?.colors ?? builtinThemes.dark;
|
||||
}
|
||||
|
||||
return resolveThemeConfig(resolved, config.themes).colors;
|
||||
}
|
||||
|
||||
// ─── HSL-to-Hex Helper ───────────────────────────────────────────────────────
|
||||
|
||||
/** Convert an HSL string (e.g. "228 20% 10%") to a hex color. */
|
||||
function hslStringToHex(hsl: string): string {
|
||||
const { h, s, l } = parseHsl(hsl);
|
||||
const [r, g, b] = hslToRgb(h, s, l);
|
||||
return rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
// ─── Color Derivation from Theme ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive a wall color from the theme's background.
|
||||
*
|
||||
* Strategy: take the background hue, warm it slightly (shift toward yellow),
|
||||
* increase saturation gently, and push lightness toward a wall-appropriate
|
||||
* range (60-85% lightness for walls).
|
||||
*/
|
||||
function deriveWallColor(themeColors: CoreThemeColors): string {
|
||||
const bg = parseHsl(themeColors.background);
|
||||
|
||||
// Warm the hue: shift slightly toward 30 (warm/golden)
|
||||
const warmHue = bg.h + (30 - bg.h) * 0.15;
|
||||
// Gentle saturation: enough to feel warm, not garish
|
||||
const wallSat = Math.min(35, Math.max(10, bg.s * 0.6 + 8));
|
||||
// Lightness: walls should be light-ish regardless of dark/light theme
|
||||
const wallLit = Math.min(88, Math.max(65, bg.l * 0.3 + 60));
|
||||
|
||||
const [r, g, b] = hslToRgb(warmHue, wallSat, wallLit);
|
||||
return rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a floor color from the theme's primary color.
|
||||
*
|
||||
* Strategy: take the primary hue, shift it toward an earthy/warm tone,
|
||||
* significantly desaturate it, and bring lightness to a floor-appropriate
|
||||
* range (35-55% — darker than walls for visual grounding).
|
||||
*/
|
||||
function deriveFloorColor(themeColors: CoreThemeColors): string {
|
||||
const primary = parseHsl(themeColors.primary);
|
||||
|
||||
// Shift hue toward warm/brown (30°), more aggressively than wall
|
||||
const earthyHue = primary.h + (30 - primary.h) * 0.35;
|
||||
// Desaturate significantly for an earthy/natural feel
|
||||
const floorSat = Math.min(40, Math.max(15, primary.s * 0.35 + 10));
|
||||
// Lightness: middle range, grounding the room
|
||||
const floorLit = Math.min(55, Math.max(38, primary.l * 0.4 + 25));
|
||||
|
||||
const hsl = formatHsl(earthyHue, floorSat, floorLit);
|
||||
return hslStringToHex(hsl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a floor accent color (darker shade of the floor color).
|
||||
*/
|
||||
function deriveFloorAccent(floorHex: string): string {
|
||||
return darkenHex(floorHex, 0.2);
|
||||
}
|
||||
|
||||
// ─── Scene Resolver ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a room scene into final concrete colors.
|
||||
*
|
||||
* When `scene.useThemeColors` is true AND themeColors are provided,
|
||||
* the wall and floor colors are derived from the theme palette.
|
||||
* Wall/floor types are always preserved from the scene declaration.
|
||||
*
|
||||
* Falls back to scene-local colors when:
|
||||
* - `scene.useThemeColors` is false
|
||||
* - `themeColors` is undefined/null
|
||||
* - Color derivation produces invalid values (defensive)
|
||||
*/
|
||||
export function resolveRoomScene(
|
||||
scene: RoomScene,
|
||||
themeColors?: CoreThemeColors,
|
||||
): ResolvedRoomScene {
|
||||
// If theme colors not requested or not available, use scene-local colors
|
||||
if (!scene.useThemeColors || !themeColors) {
|
||||
return {
|
||||
wall: { ...scene.wall },
|
||||
floor: { ...scene.floor },
|
||||
};
|
||||
}
|
||||
|
||||
// Derive colors from theme
|
||||
const wallColor = deriveWallColor(themeColors);
|
||||
const floorColor = deriveFloorColor(themeColors);
|
||||
const floorAccent = deriveFloorAccent(floorColor);
|
||||
|
||||
return {
|
||||
wall: {
|
||||
...scene.wall,
|
||||
color: wallColor,
|
||||
// Accent color is also theme-derived when applicable
|
||||
...(scene.wall.accentColor ? { accentColor: darkenHex(wallColor, 0.1) } : {}),
|
||||
},
|
||||
floor: {
|
||||
...scene.floor,
|
||||
color: floorColor,
|
||||
accentColor: floorAccent,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// src/blobbi/rooms/scene/types.ts
|
||||
|
||||
/**
|
||||
* Room Scene Types — Declarative model for Blobbi room customization.
|
||||
*
|
||||
* A "room scene" defines the visual environment of a Blobbi room:
|
||||
* wall style, floor style, and optional theme color integration.
|
||||
*
|
||||
* The scene model is purely declarative — it describes *what* to render,
|
||||
* not *how*. Rendering is handled by the scene components (WallLayer,
|
||||
* FloorLayer, RoomSceneLayer). Resolution of theme-based colors is
|
||||
* handled by the resolver module.
|
||||
*
|
||||
* Designed for future expansion:
|
||||
* - More wall/floor types can be added to the unions
|
||||
* - Furniture slots can be added to RoomScene later
|
||||
* - Per-room scenes are keyed by BlobbiRoomId in the persistence map
|
||||
*/
|
||||
|
||||
import type { BlobbiRoomId } from '../lib/room-config';
|
||||
|
||||
// ─── Wall Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Available wall surface types. */
|
||||
export type WallType = 'paint' | 'wallpaper' | 'brick';
|
||||
|
||||
/** Configuration for a room's wall. */
|
||||
export interface WallConfig {
|
||||
/** The wall surface type. */
|
||||
type: WallType;
|
||||
/** Primary wall color (hex, e.g. "#f5f0eb"). */
|
||||
color: string;
|
||||
/** Optional accent/pattern color (hex). Used by wallpaper and brick types. */
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
// ─── Floor Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Available floor surface types. */
|
||||
export type FloorType = 'wood' | 'tile' | 'carpet';
|
||||
|
||||
/** Configuration for a room's floor. */
|
||||
export interface FloorConfig {
|
||||
/** The floor surface type. */
|
||||
type: FloorType;
|
||||
/** Primary floor color (hex, e.g. "#c4a882"). */
|
||||
color: string;
|
||||
/** Optional accent color for patterns (hex). Used for wood grain, tile grout, etc. */
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
// ─── Room Scene ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A complete room scene declaration.
|
||||
*
|
||||
* This is the core scene shape — stored in kind 11127 (Blobbi House)
|
||||
* inside each room's `scene` field. Legacy kind 11125 content stored
|
||||
* this under `roomCustomization` (migrated automatically on first load).
|
||||
*
|
||||
* When `useThemeColors` is true, the resolver derives wall/floor colors
|
||||
* from the active app theme. The wall/floor *types* always come from
|
||||
* the scene, only the *colors* are influenced by the theme.
|
||||
*
|
||||
* If the theme is missing or invalid, falls back to the scene's own colors.
|
||||
*/
|
||||
export interface RoomScene {
|
||||
/** Whether to derive colors from the active app theme instead of using local colors. */
|
||||
useThemeColors: boolean;
|
||||
/** Wall configuration. */
|
||||
wall: WallConfig;
|
||||
/** Floor configuration. */
|
||||
floor: FloorConfig;
|
||||
}
|
||||
|
||||
// ─── Resolved Scene ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A resolved room scene — final colors ready for rendering.
|
||||
*
|
||||
* This is the output of the resolver. Theme colors have been applied
|
||||
* (if enabled), and all values are concrete and ready to use.
|
||||
*/
|
||||
export interface ResolvedRoomScene {
|
||||
wall: WallConfig;
|
||||
floor: FloorConfig;
|
||||
}
|
||||
|
||||
// ─── Legacy Persistence Map ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The shape of the legacy `roomCustomization` section in kind 11125 content.
|
||||
*
|
||||
* @deprecated Room scenes are now stored in kind 11127 (Blobbi House).
|
||||
* This type is retained only for migration from legacy kind 11125 data.
|
||||
* New code should read/write scenes via the house content helpers.
|
||||
*/
|
||||
export type RoomCustomizationMap = Partial<Record<BlobbiRoomId, RoomScene>>;
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
+257
-1159
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user