Harden room navigation layer for safe furniture rendering

- Add deriveNavigableRooms() that filters house roomOrder against known
  IDs and enabled state, with safe fallback to defaults
- Replace unsafe BlobbiRoomId cast with deriveNavigableRooms in dashboard
- Add effect in BlobbiRoomShell to reset current room when roomOrder
  changes and current room is no longer in the list
- Thread houseLoading into dashboard and gate BlobbiRoomShell behind it
  to prevent flicker/temporary-default rendering during bootstrap
- Export isKnownRoomId and deriveNavigableRooms from house barrel
This commit is contained in:
filemon
2026-04-08 17:49:53 -03:00
parent a26f5ae626
commit e90d657b98
4 changed files with 78 additions and 13 deletions
+2
View File
@@ -30,6 +30,8 @@ export {
DEFAULT_ROOM_ORDER,
buildDefaultHouseContent,
getDefaultRoomScene,
isKnownRoomId,
deriveNavigableRooms,
} from './lib/house-defaults';
// ── Content Helpers ──
+48
View File
@@ -134,3 +134,51 @@ export function buildDefaultHouseContent(): BlobbiHouseContent {
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];
}
@@ -75,6 +75,16 @@ export function BlobbiRoomShell({
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),
+18 -13
View File
@@ -84,8 +84,7 @@ import { getActionEmotion, type ActionType } from '@/blobbi/ui/lib/status-reacti
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
import { BlobbiRoomShell } from '@/blobbi/rooms/components/BlobbiRoomShell';
import type { BlobbiRoomContext } from '@/blobbi/rooms/lib/room-types';
import { useBlobbiHouse } from '@/blobbi/house';
import { DEFAULT_ROOM_ORDER as HOUSE_DEFAULT_ROOM_ORDER } from '@/blobbi/house/lib/house-defaults';
import { useBlobbiHouse, deriveNavigableRooms } from '@/blobbi/house';
import type { BlobbiRoomId } from '@/blobbi/rooms/lib/room-config';
@@ -160,6 +159,7 @@ function BlobbiContent() {
const {
house,
houseEvent,
isLoading: houseLoading,
updateHouseEvent,
} = useBlobbiHouse(profile?.event);
@@ -762,6 +762,7 @@ function BlobbiContent() {
profile={profile}
house={house}
houseEvent={houseEvent}
houseLoading={houseLoading}
updateHouseEvent={updateHouseEvent}
onEvolve={handleEvolve}
isHatching={isHatching}
@@ -822,6 +823,7 @@ interface BlobbiDashboardProps {
// House (kind 11127)
house: import('@/blobbi/house/lib/house-types').BlobbiHouseContent | null;
houseEvent: import('@nostrify/nostrify').NostrEvent | null;
houseLoading: boolean;
updateHouseEvent: (event: import('@nostrify/nostrify').NostrEvent) => void;
// Stage transition handlers
onEvolve: () => Promise<void>;
@@ -865,6 +867,7 @@ function BlobbiDashboard({
profile,
house,
houseEvent,
houseLoading,
updateHouseEvent,
onEvolve,
isHatching,
@@ -1363,16 +1366,12 @@ function BlobbiDashboard({
});
}, [isUsingItem, onUseItem]);
// ── Derive room order from house layout, falling back to defaults ──
// Cast is safe: house.layout.roomOrder only contains known room IDs from
// DEFAULT_ROOM_ORDER or user customisation. Unknown IDs are filtered out.
const roomOrder = useMemo((): BlobbiRoomId[] => {
const houseOrder = house?.layout.roomOrder;
if (houseOrder && houseOrder.length > 0) {
return houseOrder as BlobbiRoomId[];
}
return HOUSE_DEFAULT_ROOM_ORDER as BlobbiRoomId[];
}, [house]);
// ── Derive navigable room order from house layout ──
// Filters against known room IDs, respects enabled state, falls back safely.
const roomOrder = useMemo(
() => deriveNavigableRooms(house) as BlobbiRoomId[],
[house],
);
// ─── Build Room Context ───
// This is the single object that flows into BlobbiRoomShell → individual rooms.
@@ -1552,7 +1551,13 @@ function BlobbiDashboard({
)}
{/* ─── Room-based Layout ─── */}
<BlobbiRoomShell ctx={roomCtx} roomOrder={roomOrder} />
{houseLoading ? (
<div className="flex-1 flex items-center justify-center">
<Skeleton className="size-60 sm:size-72 rounded-full" />
</div>
) : (
<BlobbiRoomShell ctx={roomCtx} roomOrder={roomOrder} />
)}
{/* ─── Global Dialogs (shared across all rooms) ─── */}