5180 fichiers 38.5 Mo 0 téléchargements 236 vues mis à jour il y a 5 heures ui
nitro-v36-with-renderer-v36 / src / hooks / catalog / useCatalogEffects.ts
useCatalogEffects.ts 21.8 Ko Télécharger le fichier
import {
    BuildersClubFurniCountMessageEvent,
    BuildersClubPlaceRoomItemMessageComposer,
    BuildersClubPlacementWarningMessageEvent,
    BuildersClubPlaceWallItemMessageComposer,
    BuildersClubQueryFurniCountMessageComposer,
    BuildersClubSubscriptionStatusMessageEvent,
    CatalogPageMessageEvent,
    CatalogPagesListEvent,
    CatalogPublishedMessageEvent,
    FurniturePlaceComposer,
    FurniturePlacePaintComposer,
    GetRoomEngine,
    GetTickerTime,
    LegacyDataType,
    LimitedEditionSoldOutEvent,
    MarketplaceMakeOfferResult,
    ProductOfferEvent,
    PurchaseErrorMessageEvent,
    PurchaseFromCatalogComposer,
    PurchaseNotAllowedMessageEvent,
    PurchaseOKMessageEvent,
    RoomEngineObjectPlacedEvent,
    RoomObjectVariable,
    RoomPreviewer,
    Vector3d
} from '@octane/renderer';
import { useQueryClient } from '@tanstack/react-query';
import { FC, useEffect, useRef } from 'react';
import {
    CatalogPage,
    CatalogType,
    DispatchUiEvent,
    formatRecolor,
    FurniCategory,
    ICatalogNode,
    LocalizeText,
    NO_RECOLOR,
    NotificationAlertType,
    PageLocalization,
    PlacedObjectPurchaseData,
    PlaySound,
    SendMessageComposer,
    SoundNames
} from '../../api';
import {
    CatalogPurchasedEvent,
    CatalogPurchaseFailureEvent,
    CatalogPurchaseNotAllowedEvent,
    CatalogPurchaseSoldOutEvent,
    InventoryFurniAddedEvent
} from '../../events';
import { useConnectionState, useMessageEvent, useOctaneEvent, useUiEvent } from '../events';
import { useNotification } from '../notification';
import { DUMMY_PAGE_ID_FOR_OFFER_SEARCH, useCatalogStore } from './catalogStore';
import { recolorablePlacementColor } from './recolorableColorStore';
import { getNodesByOfferIdFromMap, resolveOfferPageId, restoreCatalogActivePath, RoomObjectCategory } from './useCatalog.helpers';
import { useCatalogNewAdditions } from './useCatalogNewAdditions';
import { useCatalogPlaceMultipleItems } from './useCatalogPlaceMultipleItems';
import {
    bindCatalogQueryClient,
    buildPurchasableOffer,
    cacheLateCatalogIndex,
    cacheLateCatalogPage,
    cloneCachedCatalogPages,
    dropCatalogCache,
    invalidateAllCatalogIndexes,
    invalidateCatalogPage,
    invalidateCatalogPages,
    prefetchCatalogIndex,
    readCatalogIndex,
    useCatalogIndexQuery,
    useCatalogPageQuery,
    isOfferAllowedInCatalogType,
    ParserPage
} from './useCatalogQueries';
import { useCatalogSkipPurchaseConfirmation } from './useCatalogSkipPurchaseConfirmation';

/** BuildersClubPlacementWarning type of a floor placement; anything else is a wall placement. */
const BUILDERS_CLUB_PLACEMENT_FLOOR = 0;

const scheduleWhenIdle = (run: () => void): void => {
    const idle = (globalThis as { requestIdleCallback?: (cb: () => void, options?: { timeout: number }) => number }).requestIdleCallback;

    if (typeof idle === 'function') idle(run, { timeout: 5000 });
    else setTimeout(run, 1000);
};

export const useCatalogEffects = (): void => {
    const queryClient = useQueryClient();
    const connectionState = useConnectionState();
    const { simpleAlert = null, showConfirm = null } = useNotification();
    const [catalogSkipPurchaseConfirmation] = useCatalogSkipPurchaseConfirmation();
    const [catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjectsPreference] = useCatalogPlaceMultipleItems();
    const wasAuthenticated = useRef(connectionState.authenticated);

    const isVisible = useCatalogStore((state) => state.isVisible);
    const currentType = useCatalogStore((state) => state.currentType);
    const pageId = useCatalogStore((state) => state.pageId);
    const pendingRequest = useCatalogStore((state) => state.pendingRequest);
    const pendingOfferId = useCatalogStore((state) => state.pendingOfferId);

    const indexQuery = useCatalogIndexQuery(currentType, isVisible);
    const pageQuery = useCatalogPageQuery(currentType, pageId, isVisible);

    // Bound in the render phase, not only in the effect below: store actions
    // invoked by child effects that commit in the same pass (e.g. a sibling
    // effect calling activateNode) must find the client before this hook's
    // own effect has had a chance to run. Idempotent - just replaces the
    // module-level reference, safe to call every render.
    bindCatalogQueryClient(queryClient);

    useEffect(() => {
        bindCatalogQueryClient(queryClient);

        return () => bindCatalogQueryClient(null);
    }, [queryClient]);

    // Catalog replies that arrive while no request waits for them are kept too: a one-shot listener that gave up
    // used to drop them, and the window stayed empty or on its placeholder page.
    useMessageEvent<CatalogPagesListEvent>(CatalogPagesListEvent, (event) => cacheLateCatalogIndex(queryClient, event.getParser()));
    useMessageEvent<CatalogPageMessageEvent>(CatalogPageMessageEvent, (event) => cacheLateCatalogPage(queryClient, event.getParser() as unknown as ParserPage));

    useEffect(() => {
        useCatalogStore.getState().setRoomPreviewer(new RoomPreviewer(GetRoomEngine(), ++RoomPreviewer.PREVIEW_COUNTER));

        return () => {
            const previewer = useCatalogStore.getState().roomPreviewer;

            previewer?.dispose();
            useCatalogStore.getState().setRoomPreviewer(null);
        };
    }, []);

    useEffect(() => {
        useCatalogStore.getState().setCatalogPlaceMultipleObjects(catalogPlaceMultipleObjects);
    }, [catalogPlaceMultipleObjects]);

    useEffect(
        () =>
            useCatalogStore.subscribe((state, previous) => {
                if (state.catalogPlaceMultipleObjects !== previous.catalogPlaceMultipleObjects) setCatalogPlaceMultipleObjectsPreference(state.catalogPlaceMultipleObjects);
            }),
        [setCatalogPlaceMultipleObjectsPreference]
    );

    // Index prefetch on login, and cache drop on logout.
    //
    // Deviation from the brief: this is a transition, not a level check. The
    // brief's version calls dropCatalogCache() whenever authenticated is
    // false, including on first mount - which would wipe a cache seeded
    // before mount (as this file's own tests do) and any login prefetch
    // whenever the host mounts after authentication already happened.
    // Tracking the previous value lets drop fire only on true -> false, and
    // the idle prefetch fire whenever authenticated is true, mount included.
    useEffect(() => {
        const was = wasAuthenticated.current;

        wasAuthenticated.current = connectionState.authenticated;

        if (connectionState.authenticated) {
            scheduleWhenIdle(() => prefetchCatalogIndex(useCatalogStore.getState().currentType));

            return;
        }

        if (was) dropCatalogCache();
    }, [connectionState.authenticated]);

    // Opening the catalog: the Builders Club counters. New furnidata comes from the server (FurnitureDataReload).
    useEffect(() => {
        if (!isVisible) return;

        SendMessageComposer(new BuildersClubQueryFurniCountMessageComposer());
    }, [isVisible, currentType]);

    // AIR new additions: before the default first page, so the new_additions page can win.
    useCatalogNewAdditions(isVisible, currentType, !!indexQuery.data);

    // Pending deep link and default first page, once the index is in cache.
    useEffect(() => {
        if (!indexQuery.data) return;

        useCatalogStore.getState().resolvePendingRequest();
    }, [indexQuery.data, isVisible, pendingRequest]);

    // Pending request stored while the index was missing: the query above is
    // enabled only while visible, and the store sets isVisible when it stores
    // the request, so the fetch starts on its own.

    // Page data arrival: front page items and the deep-linked offer.
    useEffect(() => {
        const data = pageQuery.data;

        if (!data || pageQuery.isPlaceholderData) return;

        if (data.frontPageItems.length) useCatalogStore.getState().setFrontPageItems(data.frontPageItems);

        useCatalogStore.getState().consumePendingOffer(data.page);
    }, [pageQuery.data, pageQuery.isPlaceholderData, pendingOfferId]);

    // Index refetched (publish, admin): keep the active path on the new tree.
    useEffect(() => {
        const data = indexQuery.data;

        if (!data) return;

        const { activeNodes, pageId: activePageId } = useCatalogStore.getState();

        if (activePageId > -1 && (!activeNodes.length || activeNodes.at(-1).pageId !== activePageId || !isNodeInTree(activeNodes.at(-1) ?? null, data.rootNode))) {
            useCatalogStore.setState({ activeNodes: restoreCatalogActivePath(data.rootNode, activePageId) });
        }
    }, [indexQuery.data]);

    useEffect(() => {
        const refresh = () => {
            useCatalogStore.getState().bumpLocalizationVersion();
            cloneCachedCatalogPages();
        };

        window.addEventListener('octane-localization-updated', refresh);

        return () => window.removeEventListener('octane-localization-updated', refresh);
    }, []);

    useMessageEvent<PurchaseOKMessageEvent>(PurchaseOKMessageEvent, (event) => {
        const { currentType: type, pageId: activePageId } = useCatalogStore.getState();

        DispatchUiEvent(new CatalogPurchasedEvent(event.getParser().offer));

        if (activePageId > -1) invalidateCatalogPage(type, activePageId);
    });

    // A refused purchase takes back the half-transparent furni a placement purchase put in the room: it used to stay
    // there, looking bought, until the room was reloaded. With no placement pending this does nothing.
    const takeBackRefusedPlacement = () => useCatalogStore.getState().resetPlacedOfferData(true);

    useMessageEvent<PurchaseErrorMessageEvent>(PurchaseErrorMessageEvent, (event) => {
        takeBackRefusedPlacement();
        DispatchUiEvent(new CatalogPurchaseFailureEvent(event.getParser().code));
    });

    useMessageEvent<PurchaseNotAllowedMessageEvent>(PurchaseNotAllowedMessageEvent, (event) => {
        takeBackRefusedPlacement();
        DispatchUiEvent(new CatalogPurchaseNotAllowedEvent(event.getParser().code));
    });

    useMessageEvent<LimitedEditionSoldOutEvent>(LimitedEditionSoldOutEvent, () => {
        const { currentType: type, pageId: activePageId } = useCatalogStore.getState();

        takeBackRefusedPlacement();

        DispatchUiEvent(new CatalogPurchaseSoldOutEvent());

        if (activePageId > -1) invalidateCatalogPage(type, activePageId);
    });

    useMessageEvent<ProductOfferEvent>(ProductOfferEvent, (event) => {
        const offerData = event.getParser().offer;

        if (!offerData || !offerData.products.length) return;

        const offer = buildPurchasableOffer(offerData);

        if (!offer) return;

        const state = useCatalogStore.getState();

        if (!isOfferAllowedInCatalogType(offer, state.currentType)) return;

        const index = readCatalogIndex(state.currentType);
        const matchingNodes = getNodesByOfferIdFromMap(offer.offerId, index?.offersToNodes, true) || getNodesByOfferIdFromMap(offer.offerId, index?.offersToNodes);
        const referencePage = state.pageOverride ?? pageQuery.data?.page ?? null;

        if (matchingNodes?.length) {
            offer.page = new CatalogPage(
                matchingNodes[0].pageId,
                referencePage?.layoutCode || 'default_3x3',
                referencePage?.localization || new PageLocalization([], []),
                [],
                referencePage?.acceptSeasonCurrencyAsCredits || false,
                referencePage?.mode ?? CatalogPage.MODE_NORMAL
            );
        } else {
            offer.page = referencePage;
        }

        state.selectCatalogOffer(offer);
    });

    useMessageEvent<MarketplaceMakeOfferResult>(MarketplaceMakeOfferResult, (event) => {
        const parser = event.getParser();

        if (!parser || !simpleAlert) return;

        const title = LocalizeText(parser.result === 1 ? 'inventory.marketplace.result.title.success' : 'inventory.marketplace.result.title.failure');

        simpleAlert(LocalizeText(`inventory.marketplace.result.${parser.result}`), NotificationAlertType.DEFAULT, null, null, title);
    });

    useMessageEvent<CatalogPublishedMessageEvent>(CatalogPublishedMessageEvent, () => {
        if (!connectionState.authenticated) return;

        // Every type: a publish while one catalog is open left the other types' indexes stale.
        invalidateAllCatalogIndexes();
        invalidateCatalogPages();
    });

    useMessageEvent<BuildersClubFurniCountMessageEvent>(BuildersClubFurniCountMessageEvent, (event) => {
        useCatalogStore.getState().setBuildersClubFurniCount(event.getParser().furniCount);
    });

    // Habbo asks before a Builders Club placement takes a trial owner's room out of the navigator: the server
    // echoes the placement and only places it once it comes back confirmed.
    useMessageEvent<BuildersClubPlacementWarningMessageEvent>(BuildersClubPlacementWarningMessageEvent, (event) => {
        const parser = event.getParser();

        if (!parser || !showConfirm) return;

        const composer =
            parser.typeCode === BUILDERS_CLUB_PLACEMENT_FLOOR
                ? new BuildersClubPlaceRoomItemMessageComposer(parser.pageId, parser.offerId, parser.extraParam, parser.x, parser.y, parser.direction, true)
                : new BuildersClubPlaceWallItemMessageComposer(parser.pageId, parser.offerId, parser.extraParam, parser.wallLocation, true);

        showConfirm(LocalizeText('room.confirm.hide_room'), () => SendMessageComposer(composer), null, null, null, LocalizeText('generic.alert.title'));
    });

    useMessageEvent<BuildersClubSubscriptionStatusMessageEvent>(BuildersClubSubscriptionStatusMessageEvent, (event) => {
        const parser = event.getParser();

        useCatalogStore.getState().setBuildersClubSubscription({
            furniLimit: parser.furniLimit,
            maxFurniLimit: parser.maxFurniLimit,
            secondsLeft: parser.secondsLeft,
            updateTime: GetTickerTime(),
            secondsLeftWithGrace: parser.secondsLeftWithGrace,
            placementBlockedByVisitors: parser.placementBlockedByVisitors,
            placementAllowedInCurrentRoom: parser.placementAllowedInCurrentRoom
        });
    });

    useUiEvent<CatalogPurchasedEvent>(CatalogPurchasedEvent.PURCHASE_SUCCESS, () => PlaySound(SoundNames.CREDITS));

    useOctaneEvent<RoomEngineObjectPlacedEvent>(RoomEngineObjectPlacedEvent.PLACED, (event) => {
        const state = useCatalogStore.getState();

        if (!state.objectMoverRequested || event.type !== RoomEngineObjectPlacedEvent.PLACED) return;

        state.resetPlacedOfferData(true);

        const purchasableOffer = state.purchasableOffer;

        if (!purchasableOffer) {
            state.resetObjectMover();

            return;
        }

        const product = purchasableOffer.product;
        let placed = false;

        if (event.category === RoomObjectCategory.WALL) {
            switch (product.furnitureData.className) {
                case 'floor':
                case 'wallpaper':
                case 'landscape':
                    placed = event.placedOnFloor || event.placedOnWall;
                    break;
                default:
                    placed = event.placedInRoom;
                    break;
            }
        } else {
            placed = event.placedInRoom;
        }

        if (!placed) {
            state.resetObjectMover();

            return;
        }

        state.setPlacedObjectPurchaseData(
            new PlacedObjectPurchaseData(event.roomId, event.objectId, event.category, event.wallLocation, event.x, event.y, event.direction, purchasableOffer)
        );

        switch (state.currentType) {
            case CatalogType.NORMAL: {
                switch (event.category) {
                    case RoomObjectCategory.FLOOR:
                        GetRoomEngine().addFurnitureFloor(
                            event.roomId,
                            event.objectId,
                            product.productClassId,
                            new Vector3d(event.x, event.y, event.z),
                            new Vector3d(event.direction),
                            0,
                            new LegacyDataType()
                        );
                        break;
                    case RoomObjectCategory.WALL: {
                        switch (product.furnitureData.className) {
                            case 'floor':
                            case 'wallpaper':
                            case 'landscape':
                                state.resetRoomPaint(product.furnitureData.className, product.extraParam);
                                break;
                            default:
                                GetRoomEngine().addFurnitureWall(
                                    event.roomId,
                                    event.objectId,
                                    product.productClassId,
                                    new Vector3d(event.x, event.y, event.z),
                                    new Vector3d(event.direction * 45),
                                    0,
                                    event.instanceData,
                                    0
                                );
                                break;
                        }
                    }
                }

                const roomObject = GetRoomEngine().getRoomObject(event.roomId, event.objectId, event.category);

                if (roomObject) roomObject.model.setValue(RoomObjectVariable.FURNITURE_ALPHA_MULTIPLIER, 0.5);

                if (catalogSkipPurchaseConfirmation && !(product && product.isUniqueLimitedItem)) {
                    const pageId = resolveOfferPageId(purchasableOffer, readCatalogIndex(state.currentType)?.offersToNodes);

                    SendMessageComposer(new PurchaseFromCatalogComposer(pageId, purchasableOffer.offerId, product.extraParam, 1));
                }

                if (state.catalogPlaceMultipleObjects) state.requestOfferToMover(purchasableOffer);
                break;
            }
            case CatalogType.BUILDER: {
                const placeBuilderItem = () => {
                    let builderPageId = purchasableOffer.page.pageId;

                    if (builderPageId === DUMMY_PAGE_ID_FOR_OFFER_SEARCH) builderPageId = -1;

                    // A recolourable furni is placed in the colour picked on its page, sent as "R,G,B".
                    const recolor = recolorablePlacementColor(product.furnitureData?.className);
                    const extraParam = recolor === NO_RECOLOR ? product.extraParam : formatRecolor(recolor);

                    switch (event.category) {
                        case RoomObjectCategory.FLOOR:
                            SendMessageComposer(
                                new BuildersClubPlaceRoomItemMessageComposer(builderPageId, purchasableOffer.offerId, extraParam, event.x, event.y, event.direction)
                            );
                            break;
                        case RoomObjectCategory.WALL:
                            SendMessageComposer(new BuildersClubPlaceWallItemMessageComposer(builderPageId, purchasableOffer.offerId, extraParam, event.wallLocation));
                            break;
                    }

                    const latest = useCatalogStore.getState();

                    if (latest.catalogPlaceMultipleObjects && latest.furniCount + 1 < latest.furniLimit) latest.requestOfferToMover(purchasableOffer);
                };

                // The server answers a placement that would hide the room with a warning to confirm.
                placeBuilderItem();
                break;
            }
        }
    });

    useUiEvent<InventoryFurniAddedEvent>(InventoryFurniAddedEvent.FURNI_ADDED, (event) => {
        const state = useCatalogStore.getState();
        const placedObjectPurchaseData = state.placedObjectPurchaseData;
        const roomEngine = GetRoomEngine();

        if (!placedObjectPurchaseData || placedObjectPurchaseData.productClassId !== event.spriteId || placedObjectPurchaseData.roomId !== roomEngine.activeRoomId) return;

        switch (event.category) {
            case FurniCategory.FLOOR: {
                const floorType = roomEngine.getRoomInstanceVariable(roomEngine.activeRoomId, RoomObjectVariable.ROOM_FLOOR_TYPE);

                if (placedObjectPurchaseData.extraParam !== floorType) SendMessageComposer(new FurniturePlacePaintComposer(event.id));
                break;
            }
            case FurniCategory.WALL_PAPER: {
                const wallType = roomEngine.getRoomInstanceVariable(roomEngine.activeRoomId, RoomObjectVariable.ROOM_WALL_TYPE);

                if (placedObjectPurchaseData.extraParam !== wallType) SendMessageComposer(new FurniturePlacePaintComposer(event.id));
                break;
            }
            case FurniCategory.LANDSCAPE: {
                const landscapeType = roomEngine.getRoomInstanceVariable(roomEngine.activeRoomId, RoomObjectVariable.ROOM_LANDSCAPE_TYPE);

                if (placedObjectPurchaseData.extraParam !== landscapeType) SendMessageComposer(new FurniturePlacePaintComposer(event.id));
                break;
            }
            default:
                SendMessageComposer(
                    new FurniturePlaceComposer(
                        event.id,
                        placedObjectPurchaseData.category,
                        placedObjectPurchaseData.wallLocation,
                        placedObjectPurchaseData.x,
                        placedObjectPurchaseData.y,
                        placedObjectPurchaseData.direction
                    )
                );
        }

        if (!state.catalogPlaceMultipleObjects) state.resetPlacedOfferData();
    });
};

const isNodeInTree = (node: ICatalogNode | null, root: ICatalogNode): boolean => {
    let current: ICatalogNode | null = node;

    while (current) {
        if (current === root) return true;

        current = current.parent;
    }

    return false;
};

export const CatalogEffectsHost: FC = () => {
    useCatalogEffects();

    return null;
};
15Sujets
1Dépôts
20Membres
2En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser