catalog: extract pure helpers + 34 cases, consume them from useCatalog

First half of the proposed `useCatalog` decomposition. The 1036-line
god-hook still owns the singleton-via-useBetween, but the pure logic
it used to define inline now lives in a dependency-free module so it
can be tested in isolation and reused by future split-out hooks
(`useCatalogData` / `useCatalogUiState` / `useCatalogActions` when
those land).

New module: `src/hooks/catalog/useCatalog.helpers.ts` (222 LOC).

- `normalizeCatalogType(type?)` — coerce the optional catalog type to
  `NORMAL` / `BUILDER`. Was a 5-line `useCallback` with an empty
  dependency array.
- `getOfferProductKeys(offer)` — produces the canonical
  `productType:id:classId` and `productType:class:className` keys
  for the resolved-offer cache.
- `findNodeById` / `findNodeByName` — DFS over the catalog tree,
  root explicitly excluded so callers can't select the synthetic
  root by mistake.
- `getNodesByOfferIdFromMap(offerId, map, onlyVisible)` — extracted
  from the closed-over `getNodesByOfferId`. The `onlyVisible`
  fallback to the full bucket when nothing visible remains is
  preserved.
- `buildCatalogNodeTree(NodeData)` — pulled out of the
  `CatalogPagesListEvent` reducer. Builds the tree and the offerId
  index in one pass; the caller now does `const { rootNode,
  offersToNodes } = buildCatalogNodeTree(parser.root)` instead of
  carrying an inline recursive walker + a local map.
- `resolveBuilderFurniPlaceableStatus(input)` — the placement
  decision tree as a pure function. The hook keeps the
  `GetRoomEngine` / `GetSessionDataManager` reads that count
  non-self, non-moderator visitors (only when the subscription has
  expired) and forwards the resulting `visitorCount` into the
  helper, so the previous early-exit semantics are preserved.

`useCatalog.ts` now imports these and removes ~140 lines of inline
copies. Net hook size: 1036 → 961 LOC. Behavior unchanged.

Tests: `tests/useCatalog.helpers.test.ts` (34 cases).

- `normalizeCatalogType` (4) — BUILDER pass-through, NORMAL
  pass-through, undefined/empty fallback, unknown string fallback.
- `getOfferProductKeys` (5) — both keys, id-only when classId<0,
  class-only when className empty, no-product short-circuit,
  empty productType short-circuit.
- `findNodeById` (5) — null input, root exclusion, immediate child,
  grandchild, miss returns null.
- `findNodeByName` (2) — match by name + root exclusion, miss.
- `getNodesByOfferIdFromMap` (5) — empty map, raw bucket pass-through,
  visible-only filter, fallback when no visible remain, miss.
- `buildCatalogNodeTree` (3) — root depth=0 + empty offer map for a
  leaf-only root, DFS traversal tracks offer→nodes across branch
  and leaf, child.parent === root.
- `resolveBuilderFurniPlaceableStatus` (10) — missing offer,
  not-in-room, owner happy path, non-owner without fallback,
  guild admin with time, furni limit reached, shared-pool override
  ignoring the limit, expired+blocked-by-visitors flag,
  expired+visitor count > 0, expired+empty room is okay.

To support the placement-status test the renderer mock gains real
numeric values for `RoomControllerLevel` (NONE..MODERATOR) and
`RoomObjectCategory` (MINIMUM..MAXIMUM); the previous string-keyed
Proxy stubs made `controllerLevel >= GUILD_ADMIN` evaluate to NaN.

Suite: 158/158 (was 124/124). `yarn typecheck` green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
simoleo89
2026-05-13 21:42:04 +02:00
parent c4018392f9
commit fd3ef7875d
6 changed files with 714 additions and 128 deletions
+222
View File
@@ -0,0 +1,222 @@
import { NodeData, RoomControllerLevel, RoomObjectCategory, RoomObjectType } from '@nitrots/nitro-renderer';
import { BuilderFurniPlaceableStatus, CatalogNode, CatalogType, ICatalogNode, IPurchasableOffer } from '../../api';
/**
* Pure helpers extracted from `useCatalog.ts`. Each function takes the
* relevant pieces of state as inputs (instead of closing over them via
* useCallback) so it can be unit-tested without rendering a React tree.
*
* Keep these dependency-free at the React layer: no `useState`, no
* refs, no `vi.fn`-able side effects beyond what the renderer SDK
* already exposes (`GetRoomEngine`, etc., which the call sites guard).
*/
/**
* The catalog has two top-level "types" — the regular catalog and the
* Builders Club catalog. Anything else maps to NORMAL. Centralising
* the coercion in one place keeps the switch from drifting between
* call sites and the message-event handlers.
*/
export const normalizeCatalogType = (type?: string): string =>
{
if(type === CatalogType.BUILDER) return CatalogType.BUILDER;
return CatalogType.NORMAL;
};
/**
* Build the canonical product-key list for a purchasable offer. Used
* by the resolved-offer cache so the same offer can be looked up by
* either `productType:id:classId` or `productType:class:className`.
*/
export const getOfferProductKeys = (offer: IPurchasableOffer | null | undefined): string[] =>
{
const keys: string[] = [];
const product = offer?.product;
if(!product) return keys;
if(product.productType && (product.productClassId >= 0))
{
keys.push(`${ product.productType }:id:${ product.productClassId }`);
}
if(product.productType && product.furnitureData?.className?.length)
{
keys.push(`${ product.productType }:class:${ product.furnitureData.className }`);
}
return keys;
};
/**
* Depth-first search by pageId. The root is excluded so callers never
* select the synthetic "root" node by mistake. Recursive but bounded
* by the tree the server sends (typically 3-4 levels deep).
*/
export const findNodeById = (id: number, node: ICatalogNode | null, rootNode: ICatalogNode | null): ICatalogNode | null =>
{
if(!node) return null;
if((node.pageId === id) && (node !== rootNode)) return node;
for(const child of node.children)
{
const found = findNodeById(id, child, rootNode);
if(found) return found;
}
return null;
};
/**
* Depth-first search by pageName. Same exclusion of the root as
* `findNodeById`.
*/
export const findNodeByName = (name: string, node: ICatalogNode | null, rootNode: ICatalogNode | null): ICatalogNode | null =>
{
if(!node) return null;
if((node.pageName === name) && (node !== rootNode)) return node;
for(const child of node.children)
{
const found = findNodeByName(name, child, rootNode);
if(found) return found;
}
return null;
};
/**
* Lookup the list of catalog nodes a given offer appears under. When
* `onlyVisible` is true the helper falls back to the full list if the
* filtered subset is empty — matches the original behavior in
* `getNodesByOfferId(offerId, true)`.
*/
export const getNodesByOfferIdFromMap = (
offerId: number,
offersToNodes: Map<number, ICatalogNode[]> | null | undefined,
onlyVisible: boolean = false
): ICatalogNode[] | null =>
{
if(!offersToNodes || !offersToNodes.size) return null;
if(onlyVisible)
{
const offers = offersToNodes.get(offerId);
const visible: ICatalogNode[] = [];
if(offers && offers.length)
{
for(const offer of offers)
{
if(offer.isVisible) visible.push(offer);
}
}
if(visible.length) return visible;
}
return offersToNodes.get(offerId) ?? null;
};
/**
* Turn the server-side NodeData tree into a CatalogNode tree paired
* with an offerId → nodes index map. Pure (besides the `new
* CatalogNode` construction).
*
* Original lived inline inside the `CatalogPagesListEvent` handler;
* extracted so the reducer is testable without rendering the hook.
*/
export const buildCatalogNodeTree = (root: NodeData): { rootNode: ICatalogNode; offersToNodes: Map<number, ICatalogNode[]> } =>
{
const offersToNodes: Map<number, ICatalogNode[]> = new Map();
const walk = (node: NodeData, depth: number, parent: ICatalogNode | null): ICatalogNode =>
{
const catalogNode = (new CatalogNode(node, depth, parent) as ICatalogNode);
for(const offerId of catalogNode.offerIds)
{
const existing = offersToNodes.get(offerId);
if(existing) existing.push(catalogNode);
else offersToNodes.set(offerId, [ catalogNode ]);
}
for(const child of node.children) catalogNode.addChild(walk(child, depth + 1, catalogNode));
return catalogNode;
};
return { rootNode: walk(root, 0, null), offersToNodes };
};
/**
* Pure-input version of the placement-status decision. The original
* `getBuilderFurniPlaceableStatus` closes over a handful of state
* slices + reads `GetRoomSession()` / `GetRoomEngine()` /
* `GetSessionDataManager()` directly. Pulling those reads up to the
* call site (the hook still does them) makes the rest of the
* decision tree testable in isolation.
*
* `roomSession` may be null (user is in the hotel view, not a room).
* `usersInRoomMinusSelf` is the number of non-moderator, non-self
* users sharing the room — only consulted when `secondsLeft <= 0`,
* because the limit-reached / not-in-room paths short-circuit first.
*/
export interface BuilderPlacementStatusInput
{
offer: IPurchasableOffer | null | undefined;
roomSession: { isGuildRoom: boolean; isRoomOwner: boolean; controllerLevel: number } | null;
secondsLeft: number;
furniCount: number;
furniLimit: number;
builderPlacementAllowedInCurrentRoom: boolean;
builderPlacementBlockedByVisitors: boolean;
/** Count of non-moderator, non-self users in the room. Only consulted when `secondsLeft <= 0`. */
visitorCount?: number;
}
export const resolveBuilderFurniPlaceableStatus = (input: BuilderPlacementStatusInput): BuilderFurniPlaceableStatus =>
{
const { offer, roomSession, secondsLeft, furniCount, furniLimit, builderPlacementAllowedInCurrentRoom, builderPlacementBlockedByVisitors, visitorCount = 0 } = input;
if(!offer) return BuilderFurniPlaceableStatus.MISSING_OFFER;
if(!roomSession) return BuilderFurniPlaceableStatus.NOT_IN_ROOM;
const canUseGuildAdminFallback = (roomSession.isGuildRoom
&& (roomSession.controllerLevel >= RoomControllerLevel.GUILD_ADMIN)
&& (secondsLeft > 0));
const usesSharedPlacementPool = (!roomSession.isRoomOwner && (builderPlacementAllowedInCurrentRoom || canUseGuildAdminFallback));
if(!roomSession.isRoomOwner && !builderPlacementAllowedInCurrentRoom && !canUseGuildAdminFallback)
{
return BuilderFurniPlaceableStatus.NOT_GROUP_ADMIN;
}
if(!usesSharedPlacementPool && ((furniCount < 0) || (furniCount >= furniLimit)))
{
return BuilderFurniPlaceableStatus.FURNI_LIMIT_REACHED;
}
if((secondsLeft <= 0) && builderPlacementBlockedByVisitors)
{
return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM;
}
if((secondsLeft <= 0) && (visitorCount > 0))
{
return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM;
}
return BuilderFurniPlaceableStatus.OKAY;
};
// Re-exports for the legacy categories so the call site in useCatalog
// doesn't need to know whether the constant comes from the renderer
// SDK enum or our own copy.
export { RoomControllerLevel, RoomObjectCategory, RoomObjectType };
+48 -123
View File
@@ -1,10 +1,11 @@
import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetRoomEngine, GetSessionDataManager, GetTickerTime, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, NodeData, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomControllerLevel, RoomEngineObjectPlacedEvent, RoomObjectCategory, RoomObjectPlacementSource, RoomObjectType, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer';
import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetRoomEngine, GetSessionDataManager, GetTickerTime, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomEngineObjectPlacedEvent, RoomObjectPlacementSource, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBetween } from 'use-between';
import { BuilderFurniPlaceableStatus, CatalogNode, CatalogPage, CatalogType, DispatchUiEvent, FurniCategory, GetFurnitureData, GetProductDataForLocalization, GetRoomSession, ICatalogNode, ICatalogPage, IPageLocalization, IProduct, IPurchasableOffer, IPurchaseOptions, LocalizeText, NotificationAlertType, Offer, PageLocalization, PlacedObjectPurchaseData, PlaySound, Product, ProductTypeEnum, RequestedPage, SearchResult, SendMessageComposer, SoundNames } from '../../api';
import { BuilderFurniPlaceableStatus, CatalogPage, CatalogType, DispatchUiEvent, FurniCategory, GetFurnitureData, GetProductDataForLocalization, GetRoomSession, ICatalogNode, ICatalogPage, IPageLocalization, IProduct, IPurchasableOffer, IPurchaseOptions, LocalizeText, NotificationAlertType, Offer, PageLocalization, PlacedObjectPurchaseData, PlaySound, Product, ProductTypeEnum, RequestedPage, SearchResult, SendMessageComposer, SoundNames } from '../../api';
import { CatalogPurchaseFailureEvent, CatalogPurchaseNotAllowedEvent, CatalogPurchaseSoldOutEvent, CatalogPurchasedEvent, InventoryFurniAddedEvent } from '../../events';
import { useMessageEvent, useNitroEvent, useUiEvent } from '../events';
import { useNotification } from '../notification';
import { buildCatalogNodeTree, findNodeById, findNodeByName, getNodesByOfferIdFromMap, getOfferProductKeys, normalizeCatalogType, resolveBuilderFurniPlaceableStatus, RoomControllerLevel, RoomObjectCategory, RoomObjectType } from './useCatalog.helpers';
import { useCatalogPlaceMultipleItems } from './useCatalogPlaceMultipleItems';
import { useCatalogSkipPurchaseConfirmation } from './useCatalogSkipPurchaseConfirmation';
@@ -62,13 +63,6 @@ const useCatalogState = () =>
setIsVisible(false);
}, []);
const normalizeCatalogType = useCallback((type?: string) =>
{
if(type === CatalogType.BUILDER) return CatalogType.BUILDER;
return CatalogType.NORMAL;
}, []);
const resetVisibleCatalogState = useCallback((type?: string) =>
{
requestedPage.current.resetRequest();
@@ -85,7 +79,7 @@ const useCatalogState = () =>
setFrontPageItems([]);
setNavigationHidden(false);
setCurrentType(normalizeCatalogType(type));
}, [ normalizeCatalogType ]);
}, []);
const openCatalogByType = useCallback((type?: string) =>
{
@@ -97,7 +91,7 @@ const useCatalogState = () =>
}
setIsVisible(true);
}, [ currentType, normalizeCatalogType, resetVisibleCatalogState ]);
}, [ currentType, resetVisibleCatalogState ]);
const toggleCatalogByType = useCallback((type?: string) =>
{
@@ -116,54 +110,60 @@ const useCatalogState = () =>
}
setIsVisible(true);
}, [ isVisible, currentType, normalizeCatalogType, resetVisibleCatalogState ]);
}, [ isVisible, currentType, resetVisibleCatalogState ]);
const getBuilderFurniPlaceableStatus = useCallback((offer: IPurchasableOffer) =>
{
if(!offer) return BuilderFurniPlaceableStatus.MISSING_OFFER;
const roomSession = GetRoomSession();
const canUseGuildAdminFallback = (!!roomSession
&& roomSession.isGuildRoom
&& (roomSession.controllerLevel >= RoomControllerLevel.GUILD_ADMIN)
&& (secondsLeft > 0));
const usesSharedPlacementPool = (!!roomSession && !roomSession.isRoomOwner && (builderPlacementAllowedInCurrentRoom || canUseGuildAdminFallback));
if(!roomSession) return BuilderFurniPlaceableStatus.NOT_IN_ROOM;
// Count non-self, non-moderator users sharing the room. Only
// matters when the subscription has expired — the pure helper
// short-circuits on the limit-reached / not-in-room paths
// first, so we skip the room scan when there's still time on
// the clock.
let visitorCount = 0;
if(!roomSession.isRoomOwner && !builderPlacementAllowedInCurrentRoom && !canUseGuildAdminFallback) return BuilderFurniPlaceableStatus.NOT_GROUP_ADMIN;
if(!usesSharedPlacementPool && ((furniCount < 0) || (furniCount >= furniLimit))) return BuilderFurniPlaceableStatus.FURNI_LIMIT_REACHED;
if((secondsLeft <= 0) && builderPlacementBlockedByVisitors) return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM;
if(secondsLeft <= 0)
if(roomSession && (secondsLeft <= 0) && !builderPlacementBlockedByVisitors)
{
const roomEngine = GetRoomEngine();
const userDataManager = roomSession.userDataManager;
const sessionDataManager = GetSessionDataManager();
if(!roomEngine || !userDataManager || !sessionDataManager) return BuilderFurniPlaceableStatus.OKAY;
const roomObjects = roomEngine.getRoomObjects(roomSession.roomId, RoomObjectCategory.UNIT);
if(!roomObjects || !roomObjects.length) return BuilderFurniPlaceableStatus.OKAY;
for(const roomObject of roomObjects)
if(roomEngine && userDataManager && sessionDataManager)
{
if(!roomObject) continue;
const roomObjects = roomEngine.getRoomObjects(roomSession.roomId, RoomObjectCategory.UNIT);
const userData = userDataManager.getUserDataByIndex(roomObject.id);
if(roomObjects && roomObjects.length)
{
for(const roomObject of roomObjects)
{
if(!roomObject) continue;
if(!userData || (userData.type !== RoomObjectType.USER)) continue;
if(userData.webID === sessionDataManager.userId) continue;
if(userData.isModerator) continue;
const userData = userDataManager.getUserDataByIndex(roomObject.id);
return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM;
if(!userData || (userData.type !== RoomObjectType.USER)) continue;
if(userData.webID === sessionDataManager.userId) continue;
if(userData.isModerator) continue;
visitorCount++;
break;
}
}
}
}
return BuilderFurniPlaceableStatus.OKAY;
return resolveBuilderFurniPlaceableStatus({
offer,
roomSession: roomSession
? { isGuildRoom: roomSession.isGuildRoom, isRoomOwner: roomSession.isRoomOwner, controllerLevel: roomSession.controllerLevel }
: null,
secondsLeft,
furniCount,
furniLimit,
builderPlacementAllowedInCurrentRoom,
builderPlacementBlockedByVisitors,
visitorCount
});
}, [ builderPlacementAllowedInCurrentRoom, builderPlacementBlockedByVisitors, furniCount, furniLimit, secondsLeft ]);
const isDraggable = useCallback((offer: IPurchasableOffer) =>
@@ -294,70 +294,12 @@ const useCatalogState = () =>
});
}, [ resetObjectMover, resetRoomPaint ]);
const getNodeById = useCallback((id: number, node: ICatalogNode) =>
{
if((node.pageId === id) && (node !== rootNode)) return node;
const getNodeById = useCallback((id: number, node: ICatalogNode) => findNodeById(id, node, rootNode), [ rootNode ]);
for(const child of node.children)
{
const found = (getNodeById(id, child));
if(found) return found;
}
return null;
}, [ rootNode ]);
const getNodeByName = useCallback((name: string, node: ICatalogNode) =>
{
if((node.pageName === name) && (node !== rootNode)) return node;
for(const child of node.children)
{
const found = (getNodeByName(name, child));
if(found) return found;
}
return null;
}, [ rootNode ]);
const getNodeByName = useCallback((name: string, node: ICatalogNode) => findNodeByName(name, node, rootNode), [ rootNode ]);
const getNodesByOfferId = useCallback((offerId: number, flag: boolean = false) =>
{
if(!offersToNodes || !offersToNodes.size) return null;
if(flag)
{
const nodes: ICatalogNode[] = [];
const offers = offersToNodes.get(offerId);
if(offers && offers.length) for(const offer of offers) (offer.isVisible && nodes.push(offer));
if(nodes.length) return nodes;
}
return offersToNodes.get(offerId);
}, [ offersToNodes ]);
const getOfferProductKeys = useCallback((offer: IPurchasableOffer) =>
{
const product = offer?.product;
const keys: string[] = [];
if(!product) return keys;
if(product.productType && (product.productClassId >= 0))
{
keys.push(`${ product.productType }:id:${ product.productClassId }`);
}
if(product.productType && product.furnitureData?.className?.length)
{
keys.push(`${ product.productType }:class:${ product.furnitureData.className }`);
}
return keys;
}, []);
getNodesByOfferIdFromMap(offerId, offersToNodes, flag), [ offersToNodes ]);
const cacheResolvedOffer = useCallback((offer: IPurchasableOffer) =>
{
@@ -365,7 +307,7 @@ const useCatalogState = () =>
{
resolvedOffersByProductKey.current.set(key, offer);
}
}, [ getOfferProductKeys ]);
}, []);
const applySelectedOffer = useCallback((offer: IPurchasableOffer) =>
{
@@ -563,27 +505,10 @@ const useCatalogState = () =>
if(parserCatalogType !== currentType) return;
const offers: Map<number, ICatalogNode[]> = new Map();
const { rootNode: builtRoot, offersToNodes: builtOffers } = buildCatalogNodeTree(parser.root);
const getCatalogNode = (node: NodeData, depth: number, parent: ICatalogNode) =>
{
const catalogNode = (new CatalogNode(node, depth, parent) as ICatalogNode);
for(const offerId of catalogNode.offerIds)
{
if(offers.has(offerId)) offers.get(offerId).push(catalogNode);
else offers.set(offerId, [ catalogNode ]);
}
depth++;
for(const child of node.children) catalogNode.addChild(getCatalogNode(child, depth, catalogNode));
return catalogNode;
};
setRootNode(getCatalogNode(parser.root, 0, null));
setOffersToNodes(offers);
setRootNode(builtRoot);
setOffersToNodes(builtOffers);
});
useMessageEvent<CatalogPageMessageEvent>(CatalogPageMessageEvent, event =>