import { useCallback, useEffect, useRef, useState } from 'react'; import { ipcChannels } from '../../../shared/ipc/contracts'; import type { SceneNpcTokensSessionEvent, SceneNpcTokensSessionPlacement, SceneNpcTokensSessionState, } from '../../../shared/types'; import { clampNpcTokenSessionScale, DEFAULT_NPC_TOKEN_SESSION_SCALE, } from '../../../shared/types/appPlayers'; import { normalizeNpcDisposition } from '../../../shared/types/npcDisposition'; import { getDndApi } from '../dndApi'; function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionState { return { ...state, scale: clampNpcTokenSessionScale(state.scale), }; } function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean { return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive; } function applyEvent( prev: SceneNpcTokensSessionState, event: SceneNpcTokensSessionEvent, ): SceneNpcTokensSessionState { if (event.kind === 'clear') { return { revision: prev.revision + 1, byPlacementId: {}, scale: DEFAULT_NPC_TOKEN_SESSION_SCALE, }; } if (event.kind === 'setScale') { const scale = clampNpcTokenSessionScale(event.scale); if (prev.scale === scale) return prev; return { ...prev, revision: prev.revision + 1, scale }; } if (event.kind === 'setDisposition') { const placementId = String(event.placementId ?? ''); if (!placementId) return prev; const disposition = normalizeNpcDisposition(event.disposition); const cur = prev.byPlacementId[placementId]; if (cur?.disposition === disposition) return prev; return { revision: prev.revision + 1, byPlacementId: { ...prev.byPlacementId, [placementId]: { ...(cur?.nx !== undefined ? { nx: cur.nx } : {}), ...(cur?.ny !== undefined ? { ny: cur.ny } : {}), disposition, ...(cur?.inactive ? { inactive: true } : {}), }, }, scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE, }; } if (event.kind === 'setInactive') { const placementId = String(event.placementId ?? ''); if (!placementId) return prev; const inactive = Boolean(event.inactive); const cur = prev.byPlacementId[placementId]; if (Boolean(cur?.inactive) === inactive) return prev; const next: SceneNpcTokensSessionPlacement = { ...(cur?.nx !== undefined ? { nx: cur.nx } : {}), ...(cur?.ny !== undefined ? { ny: cur.ny } : {}), ...(cur?.disposition ? { disposition: cur.disposition } : {}), ...(inactive ? { inactive: true } : {}), }; if (isEmptyPlacement(next)) { const { [placementId]: _removed, ...rest } = prev.byPlacementId; return { revision: prev.revision + 1, byPlacementId: rest, scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE, }; } return { revision: prev.revision + 1, byPlacementId: { ...prev.byPlacementId, [placementId]: next, }, scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE, }; } // move const placementId = String(event.placementId ?? ''); if (!placementId) return prev; const cur = prev.byPlacementId[placementId]; return { revision: prev.revision + 1, byPlacementId: { ...prev.byPlacementId, [placementId]: { nx: event.nx, ny: event.ny, ...(cur?.disposition ? { disposition: cur.disposition } : {}), ...(cur?.inactive ? { inactive: true } : {}), }, }, scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE, }; } export function useSceneNpcTokensSession(): [ SceneNpcTokensSessionState, { dispatch: (event: SceneNpcTokensSessionEvent) => void }, ] { const api = getDndApi(); const [state, setState] = useState({ revision: 0, byPlacementId: {}, scale: DEFAULT_NPC_TOKEN_SESSION_SCALE, }); const localRevRef = useRef(0); useEffect(() => { void api.invoke(ipcChannels.sceneNpcTokensSession.getState, {}).then((res) => { const next = withScale(res.state); // Не затираем более свежий optimistic/broadcast стейт устаревшим getState. if (next.revision < localRevRef.current) return; localRevRef.current = next.revision; setState(next); }); return api.on(ipcChannels.sceneNpcTokensSession.stateChanged, ({ state: incoming }) => { const next = withScale(incoming); if (next.revision < localRevRef.current) return; localRevRef.current = next.revision; setState(next); }); }, [api]); const dispatch = useCallback( (event: SceneNpcTokensSessionEvent) => { setState((prev) => { const next = applyEvent(prev, event); localRevRef.current = Math.max(localRevRef.current, next.revision); return next; }); void api.invoke(ipcChannels.sceneNpcTokensSession.dispatch, { event }); }, [api], ); return [state, { dispatch }]; }