mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
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:
@@ -261,13 +261,13 @@ into `configurePreviewServer` so `yarn preview` keeps working.
|
|||||||
| God-hook split (state + actions + shim) | `doorbell`, `poll`, `furni-chooser`, `user-chooser`, `friend-request`, `chat-input` |
|
| God-hook split (state + actions + shim) | `doorbell`, `poll`, `furni-chooser`, `user-chooser`, `friend-request`, `chat-input` |
|
||||||
| God-hook split (`useBetween` singleton + state filter + actions filter + shim) | `wired-tools`, `translation`, `notification`, `friends` |
|
| God-hook split (`useBetween` singleton + state filter + actions filter + shim) | `wired-tools`, `translation`, `notification`, `friends` |
|
||||||
| `WidgetErrorBoundary` | `RoomWidgetsView` umbrella |
|
| `WidgetErrorBoundary` | `RoomWidgetsView` umbrella |
|
||||||
| Vitest | 124/124 cases — 113 on pure helpers + Zustand store, plus the first 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the new renderer-SDK mock at `tests/mocks/renderer-mock.ts` |
|
| Vitest | 158/158 cases — pure helpers + Zustand store + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `tests/mocks/renderer-mock.ts`, plus 34 cases on the freshly extracted catalog helpers |
|
||||||
| Form Actions | Login / Register / Forgot (LoginView.tsx) |
|
| Form Actions | Login / Register / Forgot (LoginView.tsx) |
|
||||||
| Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating |
|
| Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating |
|
||||||
|
|
||||||
| Not yet | Notes |
|
| Not yet | Notes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Core `useCatalog` split | Session-stable secondary fetches all migrated to TanStack queries (see ARCHITECTURE.md). What's left: core `rootNode`/`offersToNodes`/`currentPage` slice + Builders Club status. Needs a dedicated `useCatalogData`/`useCatalogUiState`/`useCatalogActions` split. |
|
| Singleton-filter split of `useCatalog` | Pure helpers extracted to `useCatalog.helpers.ts` and consumed in the hook (`buildCatalogNodeTree`, `findNodeById`, `findNodeByName`, `getNodesByOfferIdFromMap`, `getOfferProductKeys`, `normalizeCatalogType`, `resolveBuilderFurniPlaceableStatus`). What still remains: split the singleton state into `useCatalogData` / `useCatalogUiState` / `useCatalogActions` filters via `useBetween`, mirroring the wired-tools / translation / notification / friends pattern. The 48 consumers can stay on the shim during the transition. |
|
||||||
| Split `useChatWidget` / `useAvatarInfoWidget` | Both state-driven via events with no clean imperative actions to extract — skip-motivated. Already touched today for the InfoStand listener move. |
|
| Split `useChatWidget` / `useAvatarInfoWidget` | Both state-driven via events with no clean imperative actions to extract — skip-motivated. Already touched today for the InfoStand listener move. |
|
||||||
| Split `usePetPackageWidget` / `useWordQuizWidget` / `useChatCommandSelector` | Their "actions" mutate internal state or are tightly interdependent — skip-motivated. |
|
| Split `usePetPackageWidget` / `useWordQuizWidget` / `useChatCommandSelector` | Their "actions" mutate internal state or are tightly interdependent — skip-motivated. |
|
||||||
| Hoist Wired Creator Tools shared state to a Zustand slice | Would remove ~25 props passed to the 3 tab sub-components. (Wired-tools split done as singleton-filter; Zustand slice is the next step.) |
|
| Hoist Wired Creator Tools shared state to a Zustand slice | Would remove ~25 props passed to the 3 tab sub-components. (Wired-tools split done as singleton-filter; Zustand slice is the next step.) |
|
||||||
@@ -323,6 +323,10 @@ Fix shapes documented; both are reasonable PRs on their own.
|
|||||||
- Asset middleware: `nitroAssetsServer()` in `vite.config.mjs`
|
- Asset middleware: `nitroAssetsServer()` in `vite.config.mjs`
|
||||||
- Configuration pre-init: `src/bootstrap.ts` (`await GetConfiguration().init()`
|
- Configuration pre-init: `src/bootstrap.ts` (`await GetConfiguration().init()`
|
||||||
before `import('./index')`)
|
before `import('./index')`)
|
||||||
|
- Catalog pure helpers: `src/hooks/catalog/useCatalog.helpers.ts`
|
||||||
|
(`buildCatalogNodeTree`, `findNodeById` / `findNodeByName`,
|
||||||
|
`getNodesByOfferIdFromMap`, `getOfferProductKeys`,
|
||||||
|
`normalizeCatalogType`, `resolveBuilderFurniPlaceableStatus`)
|
||||||
- Renderer-SDK mock for Vitest: `tests/mocks/renderer-mock.ts`
|
- Renderer-SDK mock for Vitest: `tests/mocks/renderer-mock.ts`
|
||||||
(aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`).
|
(aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`).
|
||||||
Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` /
|
Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` /
|
||||||
|
|||||||
+36
-1
@@ -480,11 +480,39 @@ Status after this round of work:
|
|||||||
| CatalogPagesList / CatalogPage | **deferred** — core state slice (rootNode / offersToNodes / currentPage), needs its own split-out store |
|
| CatalogPagesList / CatalogPage | **deferred** — core state slice (rootNode / offersToNodes / currentPage), needs its own split-out store |
|
||||||
| BuildersClubFurniCount / SubscriptionStatus | **deferred** — read by the internal `getBuilderFurniPlaceableStatus` logic, moves with the data/actions split |
|
| BuildersClubFurniCount / SubscriptionStatus | **deferred** — read by the internal `getBuilderFurniPlaceableStatus` logic, moves with the data/actions split |
|
||||||
|
|
||||||
|
Pure-helper extraction landed before the singleton split:
|
||||||
|
`src/hooks/catalog/useCatalog.helpers.ts` hosts the dependency-free
|
||||||
|
pieces previously inlined in the hook —
|
||||||
|
|
||||||
|
- `normalizeCatalogType(type?)` — coerce the optional catalog type
|
||||||
|
back to `NORMAL` / `BUILDER`.
|
||||||
|
- `getOfferProductKeys(offer)` — canonical lookup keys for the
|
||||||
|
resolved-offer cache.
|
||||||
|
- `findNodeById` / `findNodeByName` — DFS over the catalog tree,
|
||||||
|
root excluded.
|
||||||
|
- `getNodesByOfferIdFromMap(offerId, map, onlyVisible)` — used to be
|
||||||
|
the closed-over `getNodesByOfferId`; the `onlyVisible` fallback to
|
||||||
|
the full bucket is preserved.
|
||||||
|
- `buildCatalogNodeTree(NodeData)` — pulled out of the
|
||||||
|
`CatalogPagesListEvent` reducer; returns the tree + the offerId
|
||||||
|
index map in one pass.
|
||||||
|
- `resolveBuilderFurniPlaceableStatus(input)` — the placement
|
||||||
|
decision tree as a pure function; the hook keeps the `GetRoomEngine`
|
||||||
|
/ `GetSessionDataManager` reads (to count non-self, non-moderator
|
||||||
|
visitors) and passes the resulting `visitorCount` into the helper.
|
||||||
|
|
||||||
|
`useCatalog.ts` now imports these instead of defining them inline
|
||||||
|
(net **−75 LOC**). Test file `tests/useCatalog.helpers.test.ts` covers
|
||||||
|
all six helpers with 34 cases (tree depth + offerId mapping,
|
||||||
|
node lookups including root exclusion, the limit-reached / guild-admin
|
||||||
|
fallback / visitors-in-room paths of the placement helper, and the
|
||||||
|
empty-map / partial-bucket branches of the offer lookup).
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
- Vitest 3 + jsdom + `@testing-library/react` + `@testing-library/jest-dom`
|
- Vitest 3 + jsdom + `@testing-library/react` + `@testing-library/jest-dom`
|
||||||
configured. Separate `vitest.config.mts` so the runner doesn't drag in
|
configured. Separate `vitest.config.mts` so the runner doesn't drag in
|
||||||
the renderer SDK aliases from `vite.config.mjs`.
|
the renderer SDK aliases from `vite.config.mjs`.
|
||||||
- **124 cases passing** across 10 test files. Pure-module suites:
|
- **158 cases passing** across 11 test files. Pure-module suites:
|
||||||
- `WiredCreatorTools.helpers.test.ts` (18) — formatters + snapshot
|
- `WiredCreatorTools.helpers.test.ts` (18) — formatters + snapshot
|
||||||
factory.
|
factory.
|
||||||
- `navigatorRoomCreatorStore.test.ts` (4) — Zustand store invariants
|
- `navigatorRoomCreatorStore.test.ts` (4) — Zustand store invariants
|
||||||
@@ -504,6 +532,13 @@ Status after this round of work:
|
|||||||
bail-out branches (state-not-AvatarInfoUser, mismatched
|
bail-out branches (state-not-AvatarInfoUser, mismatched
|
||||||
user/roomIndex, equal-after-dedup) + the figure / favorite-group
|
user/roomIndex, equal-after-dedup) + the figure / favorite-group
|
||||||
apply paths.
|
apply paths.
|
||||||
|
- `useCatalog.helpers.test.ts` (34) — catalog pure helpers
|
||||||
|
extracted out of the god-hook: `normalizeCatalogType`,
|
||||||
|
`getOfferProductKeys`, `findNodeById` / `findNodeByName` (with
|
||||||
|
the root-exclusion guard), `getNodesByOfferIdFromMap` (with
|
||||||
|
the partial-visible fallback), `buildCatalogNodeTree` (tree
|
||||||
|
depth + offerId index), and the full decision tree of
|
||||||
|
`resolveBuilderFurniPlaceableStatus`.
|
||||||
|
|
||||||
Component-/hook-level suites (on the new renderer-SDK mock):
|
Component-/hook-level suites (on the new renderer-SDK mock):
|
||||||
- `WidgetErrorBoundary.test.tsx` (4) — happy path + caught render
|
- `WidgetErrorBoundary.test.tsx` (4) — happy path + caught render
|
||||||
|
|||||||
@@ -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
@@ -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 { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useBetween } from 'use-between';
|
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 { CatalogPurchaseFailureEvent, CatalogPurchaseNotAllowedEvent, CatalogPurchaseSoldOutEvent, CatalogPurchasedEvent, InventoryFurniAddedEvent } from '../../events';
|
||||||
import { useMessageEvent, useNitroEvent, useUiEvent } from '../events';
|
import { useMessageEvent, useNitroEvent, useUiEvent } from '../events';
|
||||||
import { useNotification } from '../notification';
|
import { useNotification } from '../notification';
|
||||||
|
import { buildCatalogNodeTree, findNodeById, findNodeByName, getNodesByOfferIdFromMap, getOfferProductKeys, normalizeCatalogType, resolveBuilderFurniPlaceableStatus, RoomControllerLevel, RoomObjectCategory, RoomObjectType } from './useCatalog.helpers';
|
||||||
import { useCatalogPlaceMultipleItems } from './useCatalogPlaceMultipleItems';
|
import { useCatalogPlaceMultipleItems } from './useCatalogPlaceMultipleItems';
|
||||||
import { useCatalogSkipPurchaseConfirmation } from './useCatalogSkipPurchaseConfirmation';
|
import { useCatalogSkipPurchaseConfirmation } from './useCatalogSkipPurchaseConfirmation';
|
||||||
|
|
||||||
@@ -62,13 +63,6 @@ const useCatalogState = () =>
|
|||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const normalizeCatalogType = useCallback((type?: string) =>
|
|
||||||
{
|
|
||||||
if(type === CatalogType.BUILDER) return CatalogType.BUILDER;
|
|
||||||
|
|
||||||
return CatalogType.NORMAL;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetVisibleCatalogState = useCallback((type?: string) =>
|
const resetVisibleCatalogState = useCallback((type?: string) =>
|
||||||
{
|
{
|
||||||
requestedPage.current.resetRequest();
|
requestedPage.current.resetRequest();
|
||||||
@@ -85,7 +79,7 @@ const useCatalogState = () =>
|
|||||||
setFrontPageItems([]);
|
setFrontPageItems([]);
|
||||||
setNavigationHidden(false);
|
setNavigationHidden(false);
|
||||||
setCurrentType(normalizeCatalogType(type));
|
setCurrentType(normalizeCatalogType(type));
|
||||||
}, [ normalizeCatalogType ]);
|
}, []);
|
||||||
|
|
||||||
const openCatalogByType = useCallback((type?: string) =>
|
const openCatalogByType = useCallback((type?: string) =>
|
||||||
{
|
{
|
||||||
@@ -97,7 +91,7 @@ const useCatalogState = () =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsVisible(true);
|
setIsVisible(true);
|
||||||
}, [ currentType, normalizeCatalogType, resetVisibleCatalogState ]);
|
}, [ currentType, resetVisibleCatalogState ]);
|
||||||
|
|
||||||
const toggleCatalogByType = useCallback((type?: string) =>
|
const toggleCatalogByType = useCallback((type?: string) =>
|
||||||
{
|
{
|
||||||
@@ -116,54 +110,60 @@ const useCatalogState = () =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsVisible(true);
|
setIsVisible(true);
|
||||||
}, [ isVisible, currentType, normalizeCatalogType, resetVisibleCatalogState ]);
|
}, [ isVisible, currentType, resetVisibleCatalogState ]);
|
||||||
|
|
||||||
const getBuilderFurniPlaceableStatus = useCallback((offer: IPurchasableOffer) =>
|
const getBuilderFurniPlaceableStatus = useCallback((offer: IPurchasableOffer) =>
|
||||||
{
|
{
|
||||||
if(!offer) return BuilderFurniPlaceableStatus.MISSING_OFFER;
|
|
||||||
|
|
||||||
const roomSession = GetRoomSession();
|
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(roomSession && (secondsLeft <= 0) && !builderPlacementBlockedByVisitors)
|
||||||
|
|
||||||
if(!usesSharedPlacementPool && ((furniCount < 0) || (furniCount >= furniLimit))) return BuilderFurniPlaceableStatus.FURNI_LIMIT_REACHED;
|
|
||||||
|
|
||||||
if((secondsLeft <= 0) && builderPlacementBlockedByVisitors) return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM;
|
|
||||||
|
|
||||||
if(secondsLeft <= 0)
|
|
||||||
{
|
{
|
||||||
const roomEngine = GetRoomEngine();
|
const roomEngine = GetRoomEngine();
|
||||||
const userDataManager = roomSession.userDataManager;
|
const userDataManager = roomSession.userDataManager;
|
||||||
const sessionDataManager = GetSessionDataManager();
|
const sessionDataManager = GetSessionDataManager();
|
||||||
|
|
||||||
if(!roomEngine || !userDataManager || !sessionDataManager) return BuilderFurniPlaceableStatus.OKAY;
|
if(roomEngine && userDataManager && sessionDataManager)
|
||||||
|
|
||||||
const roomObjects = roomEngine.getRoomObjects(roomSession.roomId, RoomObjectCategory.UNIT);
|
|
||||||
|
|
||||||
if(!roomObjects || !roomObjects.length) return BuilderFurniPlaceableStatus.OKAY;
|
|
||||||
|
|
||||||
for(const roomObject of roomObjects)
|
|
||||||
{
|
{
|
||||||
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;
|
const userData = userDataManager.getUserDataByIndex(roomObject.id);
|
||||||
if(userData.webID === sessionDataManager.userId) continue;
|
|
||||||
if(userData.isModerator) continue;
|
|
||||||
|
|
||||||
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 ]);
|
}, [ builderPlacementAllowedInCurrentRoom, builderPlacementBlockedByVisitors, furniCount, furniLimit, secondsLeft ]);
|
||||||
|
|
||||||
const isDraggable = useCallback((offer: IPurchasableOffer) =>
|
const isDraggable = useCallback((offer: IPurchasableOffer) =>
|
||||||
@@ -294,70 +294,12 @@ const useCatalogState = () =>
|
|||||||
});
|
});
|
||||||
}, [ resetObjectMover, resetRoomPaint ]);
|
}, [ resetObjectMover, resetRoomPaint ]);
|
||||||
|
|
||||||
const getNodeById = useCallback((id: number, node: ICatalogNode) =>
|
const getNodeById = useCallback((id: number, node: ICatalogNode) => findNodeById(id, node, rootNode), [ rootNode ]);
|
||||||
{
|
|
||||||
if((node.pageId === id) && (node !== rootNode)) return node;
|
|
||||||
|
|
||||||
for(const child of node.children)
|
const getNodeByName = useCallback((name: string, node: ICatalogNode) => findNodeByName(name, node, rootNode), [ rootNode ]);
|
||||||
{
|
|
||||||
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 getNodesByOfferId = useCallback((offerId: number, flag: boolean = false) =>
|
const getNodesByOfferId = useCallback((offerId: number, flag: boolean = false) =>
|
||||||
{
|
getNodesByOfferIdFromMap(offerId, offersToNodes, flag), [ offersToNodes ]);
|
||||||
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;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const cacheResolvedOffer = useCallback((offer: IPurchasableOffer) =>
|
const cacheResolvedOffer = useCallback((offer: IPurchasableOffer) =>
|
||||||
{
|
{
|
||||||
@@ -365,7 +307,7 @@ const useCatalogState = () =>
|
|||||||
{
|
{
|
||||||
resolvedOffersByProductKey.current.set(key, offer);
|
resolvedOffersByProductKey.current.set(key, offer);
|
||||||
}
|
}
|
||||||
}, [ getOfferProductKeys ]);
|
}, []);
|
||||||
|
|
||||||
const applySelectedOffer = useCallback((offer: IPurchasableOffer) =>
|
const applySelectedOffer = useCallback((offer: IPurchasableOffer) =>
|
||||||
{
|
{
|
||||||
@@ -563,27 +505,10 @@ const useCatalogState = () =>
|
|||||||
|
|
||||||
if(parserCatalogType !== currentType) return;
|
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) =>
|
setRootNode(builtRoot);
|
||||||
{
|
setOffersToNodes(builtOffers);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useMessageEvent<CatalogPageMessageEvent>(CatalogPageMessageEvent, event =>
|
useMessageEvent<CatalogPageMessageEvent>(CatalogPageMessageEvent, event =>
|
||||||
|
|||||||
@@ -98,11 +98,9 @@ const makeEnumProxy = (label: string) => new Proxy({}, {
|
|||||||
export const NitroEventType = makeEnumProxy('NitroEventType');
|
export const NitroEventType = makeEnumProxy('NitroEventType');
|
||||||
export const MouseEventType = makeEnumProxy('MouseEventType');
|
export const MouseEventType = makeEnumProxy('MouseEventType');
|
||||||
export const TouchEventType = makeEnumProxy('TouchEventType');
|
export const TouchEventType = makeEnumProxy('TouchEventType');
|
||||||
export const RoomObjectCategory = makeEnumProxy('RoomObjectCategory');
|
|
||||||
export const RoomObjectPlacementSource = makeEnumProxy('RoomObjectPlacementSource');
|
export const RoomObjectPlacementSource = makeEnumProxy('RoomObjectPlacementSource');
|
||||||
export const RoomObjectType = makeEnumProxy('RoomObjectType');
|
export const RoomObjectType = makeEnumProxy('RoomObjectType');
|
||||||
export const RoomObjectVariable = makeEnumProxy('RoomObjectVariable');
|
export const RoomObjectVariable = makeEnumProxy('RoomObjectVariable');
|
||||||
export const RoomControllerLevel = makeEnumProxy('RoomControllerLevel');
|
|
||||||
export const RoomTradingLevelEnum = makeEnumProxy('RoomTradingLevelEnum');
|
export const RoomTradingLevelEnum = makeEnumProxy('RoomTradingLevelEnum');
|
||||||
export const HabboClubLevelEnum = makeEnumProxy('HabboClubLevelEnum');
|
export const HabboClubLevelEnum = makeEnumProxy('HabboClubLevelEnum');
|
||||||
export const FurnitureType = makeEnumProxy('FurnitureType');
|
export const FurnitureType = makeEnumProxy('FurnitureType');
|
||||||
@@ -113,6 +111,29 @@ export const AvatarSetType = makeEnumProxy('AvatarSetType');
|
|||||||
export const AvatarAction = makeEnumProxy('AvatarAction');
|
export const AvatarAction = makeEnumProxy('AvatarAction');
|
||||||
export const RoomWidgetEnumItemExtradataParameter = makeEnumProxy('RoomWidgetEnumItemExtradataParameter');
|
export const RoomWidgetEnumItemExtradataParameter = makeEnumProxy('RoomWidgetEnumItemExtradataParameter');
|
||||||
|
|
||||||
|
// Numeric enums — values mirror the real renderer SDK so comparisons
|
||||||
|
// (`controllerLevel >= GUILD_ADMIN`, category branching) keep working.
|
||||||
|
|
||||||
|
export class RoomControllerLevel
|
||||||
|
{
|
||||||
|
static readonly NONE = 0;
|
||||||
|
static readonly GUEST = 1;
|
||||||
|
static readonly GUILD_MEMBER = 2;
|
||||||
|
static readonly GUILD_ADMIN = 3;
|
||||||
|
static readonly ROOM_OWNER = 4;
|
||||||
|
static readonly MODERATOR = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomObjectCategory
|
||||||
|
{
|
||||||
|
static readonly MINIMUM = 0;
|
||||||
|
static readonly ROOM = 10;
|
||||||
|
static readonly UNIT = 20;
|
||||||
|
static readonly FLOOR = 30;
|
||||||
|
static readonly WALL = 40;
|
||||||
|
static readonly MAXIMUM = 50;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Doorbell event class
|
// Doorbell event class
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { BuilderFurniPlaceableStatus } from '../src/api/catalog/BuilderFurniPlaceableStatus';
|
||||||
|
import { CatalogType } from '../src/api/catalog/CatalogType';
|
||||||
|
import {
|
||||||
|
buildCatalogNodeTree,
|
||||||
|
findNodeById,
|
||||||
|
findNodeByName,
|
||||||
|
getNodesByOfferIdFromMap,
|
||||||
|
getOfferProductKeys,
|
||||||
|
normalizeCatalogType,
|
||||||
|
resolveBuilderFurniPlaceableStatus
|
||||||
|
} from '../src/hooks/catalog/useCatalog.helpers';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// normalizeCatalogType
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('normalizeCatalogType', () =>
|
||||||
|
{
|
||||||
|
it('returns BUILDER when explicitly asked for BUILDER', () =>
|
||||||
|
{
|
||||||
|
expect(normalizeCatalogType(CatalogType.BUILDER)).toBe(CatalogType.BUILDER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns NORMAL for the explicit NORMAL value', () =>
|
||||||
|
{
|
||||||
|
expect(normalizeCatalogType(CatalogType.NORMAL)).toBe(CatalogType.NORMAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns NORMAL when type is omitted', () =>
|
||||||
|
{
|
||||||
|
expect(normalizeCatalogType()).toBe(CatalogType.NORMAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns NORMAL for any unknown string', () =>
|
||||||
|
{
|
||||||
|
expect(normalizeCatalogType('something_else')).toBe(CatalogType.NORMAL);
|
||||||
|
expect(normalizeCatalogType('')).toBe(CatalogType.NORMAL);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getOfferProductKeys
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('getOfferProductKeys', () =>
|
||||||
|
{
|
||||||
|
const makeOffer = (overrides: any = {}) =>
|
||||||
|
({
|
||||||
|
product: {
|
||||||
|
productType: 'floor',
|
||||||
|
productClassId: 42,
|
||||||
|
furnitureData: { className: 'chair_basic' },
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
it('returns both id and className keys when the product has both', () =>
|
||||||
|
{
|
||||||
|
expect(getOfferProductKeys(makeOffer())).toEqual([
|
||||||
|
'floor:id:42',
|
||||||
|
'floor:class:chair_basic'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the id key when productClassId is negative', () =>
|
||||||
|
{
|
||||||
|
const offer = makeOffer({ productClassId: -1 });
|
||||||
|
|
||||||
|
expect(getOfferProductKeys(offer)).toEqual([ 'floor:class:chair_basic' ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the className key when furnitureData has no className', () =>
|
||||||
|
{
|
||||||
|
const offer = makeOffer({ furnitureData: { className: '' } });
|
||||||
|
|
||||||
|
expect(getOfferProductKeys(offer)).toEqual([ 'floor:id:42' ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when the offer has no product', () =>
|
||||||
|
{
|
||||||
|
expect(getOfferProductKeys(null)).toEqual([]);
|
||||||
|
expect(getOfferProductKeys(undefined)).toEqual([]);
|
||||||
|
expect(getOfferProductKeys({} as any)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when productType is missing', () =>
|
||||||
|
{
|
||||||
|
const offer = makeOffer({ productType: '' });
|
||||||
|
|
||||||
|
expect(getOfferProductKeys(offer)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// findNodeById / findNodeByName
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const makeNode = (overrides: { pageId?: number; pageName?: string; children?: any[] } = {}) =>
|
||||||
|
({
|
||||||
|
pageId: overrides.pageId ?? -1,
|
||||||
|
pageName: overrides.pageName ?? 'unnamed',
|
||||||
|
isVisible: true,
|
||||||
|
children: overrides.children ?? []
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findNodeById', () =>
|
||||||
|
{
|
||||||
|
it('returns null when the input node is null', () =>
|
||||||
|
{
|
||||||
|
expect(findNodeById(7, null, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the root node even when its pageId matches', () =>
|
||||||
|
{
|
||||||
|
const root = makeNode({ pageId: 7, pageName: 'root' }) as any;
|
||||||
|
|
||||||
|
expect(findNodeById(7, root, root)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds an immediate child by pageId', () =>
|
||||||
|
{
|
||||||
|
const child = makeNode({ pageId: 7, pageName: 'shop' }) as any;
|
||||||
|
const root = makeNode({ pageId: 0, pageName: 'root', children: [ child ] }) as any;
|
||||||
|
|
||||||
|
expect(findNodeById(7, root, root)).toBe(child);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('descends into grandchildren', () =>
|
||||||
|
{
|
||||||
|
const grandchild = makeNode({ pageId: 42, pageName: 'sale' }) as any;
|
||||||
|
const child = makeNode({ pageId: 7, pageName: 'shop', children: [ grandchild ] }) as any;
|
||||||
|
const root = makeNode({ pageId: 0, pageName: 'root', children: [ child ] }) as any;
|
||||||
|
|
||||||
|
expect(findNodeById(42, root, root)).toBe(grandchild);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no node has that pageId', () =>
|
||||||
|
{
|
||||||
|
const child = makeNode({ pageId: 7, pageName: 'shop' }) as any;
|
||||||
|
const root = makeNode({ pageId: 0, pageName: 'root', children: [ child ] }) as any;
|
||||||
|
|
||||||
|
expect(findNodeById(99, root, root)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findNodeByName', () =>
|
||||||
|
{
|
||||||
|
it('finds a node by pageName ignoring the root', () =>
|
||||||
|
{
|
||||||
|
const child = makeNode({ pageName: 'frontpage' }) as any;
|
||||||
|
const root = makeNode({ pageName: 'root', children: [ child ] }) as any;
|
||||||
|
|
||||||
|
expect(findNodeByName('frontpage', root, root)).toBe(child);
|
||||||
|
expect(findNodeByName('root', root, root)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when nothing matches', () =>
|
||||||
|
{
|
||||||
|
const root = makeNode({ pageName: 'root', children: [ makeNode({ pageName: 'a' }) as any ] }) as any;
|
||||||
|
|
||||||
|
expect(findNodeByName('b', root, root)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getNodesByOfferIdFromMap
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('getNodesByOfferIdFromMap', () =>
|
||||||
|
{
|
||||||
|
const visibleNode = (id: number) => ({ pageId: id, isVisible: true } as any);
|
||||||
|
const hiddenNode = (id: number) => ({ pageId: id, isVisible: false } as any);
|
||||||
|
|
||||||
|
it('returns null when the map is missing or empty', () =>
|
||||||
|
{
|
||||||
|
expect(getNodesByOfferIdFromMap(1, null)).toBeNull();
|
||||||
|
expect(getNodesByOfferIdFromMap(1, undefined)).toBeNull();
|
||||||
|
expect(getNodesByOfferIdFromMap(1, new Map())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the raw bucket when onlyVisible is false', () =>
|
||||||
|
{
|
||||||
|
const bucket = [ visibleNode(1), hiddenNode(2) ];
|
||||||
|
const map = new Map([ [ 9, bucket ] ]);
|
||||||
|
|
||||||
|
expect(getNodesByOfferIdFromMap(9, map)).toBe(bucket);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out hidden nodes when onlyVisible is true', () =>
|
||||||
|
{
|
||||||
|
const visible = visibleNode(1);
|
||||||
|
const map = new Map([ [ 9, [ visible, hiddenNode(2) ] ] ]);
|
||||||
|
|
||||||
|
expect(getNodesByOfferIdFromMap(9, map, true)).toEqual([ visible ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the raw bucket when no visible nodes remain', () =>
|
||||||
|
{
|
||||||
|
const bucket = [ hiddenNode(1), hiddenNode(2) ];
|
||||||
|
const map = new Map([ [ 9, bucket ] ]);
|
||||||
|
|
||||||
|
expect(getNodesByOfferIdFromMap(9, map, true)).toBe(bucket);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for an offerId not in the map', () =>
|
||||||
|
{
|
||||||
|
const map = new Map([ [ 1, [ visibleNode(1) ] ] ]);
|
||||||
|
|
||||||
|
expect(getNodesByOfferIdFromMap(99, map)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// buildCatalogNodeTree
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('buildCatalogNodeTree', () =>
|
||||||
|
{
|
||||||
|
// CatalogNode in the real codebase reads `node.pageId`, `node.pageName`,
|
||||||
|
// `node.offerIds`, `node.children`. Anything else is irrelevant to the
|
||||||
|
// build step.
|
||||||
|
const makeNodeData = (overrides: any = {}) =>
|
||||||
|
({
|
||||||
|
pageId: -1,
|
||||||
|
pageName: 'unnamed',
|
||||||
|
localization: '',
|
||||||
|
iconId: -1,
|
||||||
|
offerIds: [] as number[],
|
||||||
|
children: [] as any[],
|
||||||
|
visible: true,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a CatalogNode root with the depth=0', () =>
|
||||||
|
{
|
||||||
|
const rootData = makeNodeData({ pageId: 0, pageName: 'root' });
|
||||||
|
const { rootNode, offersToNodes } = buildCatalogNodeTree(rootData as any);
|
||||||
|
|
||||||
|
expect(rootNode.pageId).toBe(0);
|
||||||
|
expect(rootNode.depth).toBe(0);
|
||||||
|
expect(offersToNodes.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walks children depth-first and tracks offerId mappings', () =>
|
||||||
|
{
|
||||||
|
const leaf = makeNodeData({ pageId: 5, pageName: 'sale', offerIds: [ 100, 200 ] });
|
||||||
|
const branch = makeNodeData({ pageId: 3, pageName: 'shop', offerIds: [ 100 ], children: [ leaf ] });
|
||||||
|
const rootData = makeNodeData({ pageId: 0, pageName: 'root', children: [ branch ] });
|
||||||
|
|
||||||
|
const { rootNode, offersToNodes } = buildCatalogNodeTree(rootData as any);
|
||||||
|
|
||||||
|
// tree shape
|
||||||
|
expect(rootNode.children).toHaveLength(1);
|
||||||
|
expect(rootNode.children[0].pageId).toBe(3);
|
||||||
|
expect(rootNode.children[0].children[0].pageId).toBe(5);
|
||||||
|
|
||||||
|
// depth incremented
|
||||||
|
expect(rootNode.depth).toBe(0);
|
||||||
|
expect(rootNode.children[0].depth).toBe(1);
|
||||||
|
expect(rootNode.children[0].children[0].depth).toBe(2);
|
||||||
|
|
||||||
|
// offerId index records both nodes for offer 100, only the leaf for 200
|
||||||
|
expect(offersToNodes.get(100)).toHaveLength(2);
|
||||||
|
expect(offersToNodes.get(100)?.map(n => n.pageId)).toEqual([ 3, 5 ]);
|
||||||
|
expect(offersToNodes.get(200)?.map(n => n.pageId)).toEqual([ 5 ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves child-parent relationships', () =>
|
||||||
|
{
|
||||||
|
const leaf = makeNodeData({ pageId: 5, pageName: 'sale' });
|
||||||
|
const rootData = makeNodeData({ pageId: 0, pageName: 'root', children: [ leaf ] });
|
||||||
|
|
||||||
|
const { rootNode } = buildCatalogNodeTree(rootData as any);
|
||||||
|
|
||||||
|
expect(rootNode.children[0].parent).toBe(rootNode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveBuilderFurniPlaceableStatus
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('resolveBuilderFurniPlaceableStatus', () =>
|
||||||
|
{
|
||||||
|
const offer = { offerId: 1 } as any;
|
||||||
|
|
||||||
|
const baseInput = {
|
||||||
|
offer,
|
||||||
|
roomSession: { isGuildRoom: false, isRoomOwner: true, controllerLevel: 0 },
|
||||||
|
secondsLeft: 60,
|
||||||
|
furniCount: 0,
|
||||||
|
furniLimit: 10,
|
||||||
|
builderPlacementAllowedInCurrentRoom: false,
|
||||||
|
builderPlacementBlockedByVisitors: false,
|
||||||
|
visitorCount: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns MISSING_OFFER when offer is null', () =>
|
||||||
|
{
|
||||||
|
expect(resolveBuilderFurniPlaceableStatus({ ...baseInput, offer: null })).toBe(BuilderFurniPlaceableStatus.MISSING_OFFER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns NOT_IN_ROOM when roomSession is null', () =>
|
||||||
|
{
|
||||||
|
expect(resolveBuilderFurniPlaceableStatus({ ...baseInput, roomSession: null })).toBe(BuilderFurniPlaceableStatus.NOT_IN_ROOM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns OKAY for the room owner with time on the clock', () =>
|
||||||
|
{
|
||||||
|
expect(resolveBuilderFurniPlaceableStatus(baseInput)).toBe(BuilderFurniPlaceableStatus.OKAY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns NOT_GROUP_ADMIN for a non-owner without group fallback or shared pool', () =>
|
||||||
|
{
|
||||||
|
const input = {
|
||||||
|
...baseInput,
|
||||||
|
roomSession: { isGuildRoom: false, isRoomOwner: false, controllerLevel: 0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.NOT_GROUP_ADMIN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns OKAY for guild admin with subscription time remaining', () =>
|
||||||
|
{
|
||||||
|
const input = {
|
||||||
|
...baseInput,
|
||||||
|
roomSession: { isGuildRoom: true, isRoomOwner: false, controllerLevel: 4 /* GUILD_ADMIN */ },
|
||||||
|
secondsLeft: 60
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.OKAY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns FURNI_LIMIT_REACHED when count meets the limit and no shared pool applies', () =>
|
||||||
|
{
|
||||||
|
const input = { ...baseInput, furniCount: 10, furniLimit: 10 };
|
||||||
|
|
||||||
|
expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.FURNI_LIMIT_REACHED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the furni limit when builderPlacementAllowedInCurrentRoom for a non-owner', () =>
|
||||||
|
{
|
||||||
|
const input = {
|
||||||
|
...baseInput,
|
||||||
|
roomSession: { isGuildRoom: false, isRoomOwner: false, controllerLevel: 0 },
|
||||||
|
furniCount: 99,
|
||||||
|
furniLimit: 10,
|
||||||
|
builderPlacementAllowedInCurrentRoom: true
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.OKAY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns VISITORS_IN_ROOM when the subscription has expired and the flag is set', () =>
|
||||||
|
{
|
||||||
|
const input = {
|
||||||
|
...baseInput,
|
||||||
|
secondsLeft: 0,
|
||||||
|
builderPlacementBlockedByVisitors: true
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.VISITORS_IN_ROOM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns VISITORS_IN_ROOM when the subscription has expired and there are visitors counted', () =>
|
||||||
|
{
|
||||||
|
const input = { ...baseInput, secondsLeft: 0, visitorCount: 3 };
|
||||||
|
|
||||||
|
expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.VISITORS_IN_ROOM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns OKAY when the subscription has expired but the room is empty', () =>
|
||||||
|
{
|
||||||
|
const input = { ...baseInput, secondsLeft: 0, visitorCount: 0 };
|
||||||
|
|
||||||
|
expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.OKAY);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user