import { useCallback, useEffect, useRef, useState } from 'react'; import { ipcChannels } from '../../../shared/ipc/contracts'; import type { SceneNpcSessionSpawn, SceneNpcTokensSessionEvent, SceneNpcTokensSessionPlacement, SceneNpcTokensSessionState, } from '../../../shared/types'; import { asSceneNpcTokenId, clampNpcTokenSessionScale, clampSceneNpcTokenSizeN, DEFAULT_NPC_TOKEN_SESSION_SCALE, DEFAULT_SCENE_NPC_TOKEN_SIZE_N, emptySceneNpcTokensSessionState, newSceneNpcSessionSpawnId, } from '../../../shared/types/appPlayers'; import { asNpcId } from '../../../shared/types/ids'; import { normalizeNpcDisposition } from '../../../shared/types/npcDisposition'; import { getDndApi } from '../dndApi'; function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionState { return { ...state, scale: clampNpcTokenSessionScale(state.scale), spawned: state.spawned ?? {}, }; } function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean { return ( p.nx === undefined && p.ny === undefined && p.sizeN === undefined && !p.disposition && !p.inactive ); } function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcTokensSessionPlacement { return { ...(prev?.nx !== undefined ? { nx: prev.nx } : {}), ...(prev?.ny !== undefined ? { ny: prev.ny } : {}), ...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}), ...(prev?.disposition ? { disposition: prev.disposition } : {}), ...(prev?.inactive ? { inactive: true } : {}), }; } function applyEvent( prev: SceneNpcTokensSessionState, event: SceneNpcTokensSessionEvent, ): SceneNpcTokensSessionState { const spawned = prev.spawned ?? {}; if (event.kind === 'clear') { return emptySceneNpcTokensSessionState(prev.revision + 1); } if (event.kind === 'setScale') { const scale = clampNpcTokenSessionScale(event.scale); if (prev.scale === scale) return prev; return { ...prev, revision: prev.revision + 1, scale, spawned }; } if (event.kind === 'spawn') { const npcId = String(event.npcId ?? ''); const sceneId = String(event.sceneId ?? ''); if (!npcId || !sceneId) return prev; const id = event.placementId ? asSceneNpcTokenId(String(event.placementId)) : newSceneNpcSessionSpawnId(); const spawn: SceneNpcSessionSpawn = { id, sceneId, npcId: asNpcId(npcId), nx: Math.max(0, Math.min(1, event.nx)), ny: Math.max(0, Math.min(1, event.ny)), sizeN: clampSceneNpcTokenSizeN( typeof event.sizeN === 'number' ? event.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N, ), disposition: normalizeNpcDisposition(event.disposition), }; return { ...prev, revision: prev.revision + 1, spawned: { ...spawned, [String(id)]: spawn }, }; } if (event.kind === 'despawn') { const placementId = String(event.placementId ?? ''); if (!placementId) return prev; if (!(placementId in spawned) && !(placementId in prev.byPlacementId)) return prev; const { [placementId]: _s, ...nextSpawned } = spawned; const { [placementId]: _p, ...byPlacementId } = prev.byPlacementId; return { ...prev, revision: prev.revision + 1, byPlacementId, spawned: nextSpawned, }; } if (event.kind === 'setDisposition') { const placementId = String(event.placementId ?? ''); if (!placementId) return prev; const disposition = normalizeNpcDisposition(event.disposition); const cur = prev.byPlacementId[placementId]; const spawn = spawned[placementId]; if (cur?.disposition === disposition && (!spawn || spawn.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 } : {}), ...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}), disposition, ...(cur?.inactive ? { inactive: true } : {}), }, }, scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE, spawned: spawn ? { ...spawned, [placementId]: { ...spawn, disposition } } : spawned, }; } 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?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}), ...(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, spawned, }; } return { revision: prev.revision + 1, byPlacementId: { ...prev.byPlacementId, [placementId]: next, }, scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE, spawned, }; } if (event.kind === 'setSize') { const placementId = String(event.placementId ?? ''); if (!placementId) return prev; const sizeN = clampSceneNpcTokenSizeN(event.sizeN); const cur = prev.byPlacementId[placementId]; const spawn = spawned[placementId]; if (cur?.sizeN === sizeN && (!spawn || spawn.sizeN === sizeN)) return prev; return { revision: prev.revision + 1, byPlacementId: { ...prev.byPlacementId, [placementId]: { ...restOf(cur), sizeN }, }, scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE, spawned: spawn ? { ...spawned, [placementId]: { ...spawn, sizeN } } : spawned, }; } // move const placementId = String(event.placementId ?? ''); if (!placementId) return prev; const cur = prev.byPlacementId[placementId]; const spawn = spawned[placementId]; return { revision: prev.revision + 1, byPlacementId: { ...prev.byPlacementId, [placementId]: { nx: event.nx, ny: event.ny, ...(cur?.sizeN !== undefined ? { sizeN: cur.sizeN } : {}), ...(cur?.disposition ? { disposition: cur.disposition } : {}), ...(cur?.inactive ? { inactive: true } : {}), }, }, scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE, spawned: spawn ? { ...spawned, [placementId]: { ...spawn, nx: event.nx, ny: event.ny } } : spawned, }; } export function useSceneNpcTokensSession(): [ SceneNpcTokensSessionState, { dispatch: (event: SceneNpcTokensSessionEvent) => void }, ] { const api = getDndApi(); const [state, setState] = useState(() => emptySceneNpcTokensSessionState(0), ); const localRevRef = useRef(0); useEffect(() => { void api.invoke(ipcChannels.sceneNpcTokensSession.getState, {}).then((res) => { const next = withScale(res.state); 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 }]; }