import { clampNpcTokenSessionScale, clampSceneNpcTokenSizeN, DEFAULT_NPC_TOKEN_SESSION_SCALE, DEFAULT_SCENE_NPC_TOKEN_SIZE_N, asSceneNpcTokenId, emptySceneNpcTokensSessionState, newSceneNpcSessionSpawnId, type SceneNpcSessionSpawn, type SceneNpcTokensSessionEvent, type SceneNpcTokensSessionPlacement, type SceneNpcTokensSessionState, } from '../../shared/types/appPlayers'; import { asNpcId } from '../../shared/types/ids'; import { normalizeNpcDisposition } from '../../shared/types/npcDisposition'; import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation'; 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 withMove( prev: SceneNpcTokensSessionPlacement | undefined, nx: number, ny: number, ): SceneNpcTokensSessionPlacement { return { ...restOf(prev), nx, ny, }; } function withoutPlacement( state: SceneNpcTokensSessionState, placementId: string, ): SceneNpcTokensSessionState { if (!(placementId in state.byPlacementId)) return state; const { [placementId]: _removed, ...rest } = state.byPlacementId; return { revision: state.revision + 1, byPlacementId: rest, scale: state.scale, spawned: state.spawned, }; } export class SceneNpcTokensSessionStore { private state: SceneNpcTokensSessionState = emptySceneNpcTokensSessionState(); getState(): SceneNpcTokensSessionState { return this.state; } reset(): SceneNpcTokensSessionState { if ( Object.keys(this.state.byPlacementId).length === 0 && Object.keys(this.state.spawned).length === 0 && this.state.scale === DEFAULT_NPC_TOKEN_SESSION_SCALE ) { return this.state; } this.state = emptySceneNpcTokensSessionState(this.state.revision + 1); return this.state; } /** Rotate session position overrides with the scene preview (CSS rotate steps). */ rotateMapCwSteps(steps: number): SceneNpcTokensSessionState { const n = ((steps % 4) + 4) % 4; if (n === 0) return this.state; const ids = Object.keys(this.state.byPlacementId); const spawnIds = Object.keys(this.state.spawned); if (ids.length === 0 && spawnIds.length === 0) return this.state; let changed = false; const byPlacementId: SceneNpcTokensSessionState['byPlacementId'] = {}; for (const id of ids) { const prev = this.state.byPlacementId[id]; if (!prev) continue; if (typeof prev.nx === 'number' && typeof prev.ny === 'number') { const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n); byPlacementId[id] = { ...prev, nx: p.nx, ny: p.ny }; changed = true; } else { byPlacementId[id] = prev; } } const spawned: SceneNpcTokensSessionState['spawned'] = {}; for (const id of spawnIds) { const prev = this.state.spawned[id]; if (!prev) continue; const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n); spawned[id] = { ...prev, nx: p.nx, ny: p.ny }; changed = true; } if (!changed) return this.state; this.state = { revision: this.state.revision + 1, byPlacementId, scale: this.state.scale, spawned, }; return this.state; } dispatch(event: SceneNpcTokensSessionEvent): SceneNpcTokensSessionState { switch (event.kind) { case 'clear': return this.reset(); case 'setScale': { const scale = clampNpcTokenSessionScale(event.scale); if (this.state.scale === scale) return this.state; this.state = { ...this.state, revision: this.state.revision + 1, scale, }; return this.state; } case 'spawn': { const npcId = String(event.npcId ?? ''); const sceneId = String(event.sceneId ?? ''); if (!npcId || !sceneId) return this.state; const nx = Math.max(0, Math.min(1, event.nx)); const ny = Math.max(0, Math.min(1, event.ny)); if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state; const id = event.placementId ? asSceneNpcTokenId(String(event.placementId)) : newSceneNpcSessionSpawnId(); const spawn: SceneNpcSessionSpawn = { id, sceneId, npcId: asNpcId(npcId), nx, ny, sizeN: clampSceneNpcTokenSizeN( typeof event.sizeN === 'number' ? event.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N, ), disposition: normalizeNpcDisposition(event.disposition), }; this.state = { revision: this.state.revision + 1, byPlacementId: this.state.byPlacementId, scale: this.state.scale, spawned: { ...this.state.spawned, [String(spawn.id)]: spawn }, }; return this.state; } case 'despawn': { const placementId = String(event.placementId ?? ''); if (!placementId) return this.state; if (!(placementId in this.state.spawned) && !(placementId in this.state.byPlacementId)) { return this.state; } const { [placementId]: _s, ...spawned } = this.state.spawned; const { [placementId]: _p, ...byPlacementId } = this.state.byPlacementId; this.state = { revision: this.state.revision + 1, byPlacementId, scale: this.state.scale, spawned, }; return this.state; } case 'move': { const placementId = String(event.placementId ?? ''); if (!placementId) return this.state; const nx = Math.max(0, Math.min(1, event.nx)); const ny = Math.max(0, Math.min(1, event.ny)); if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state; const prev = this.state.byPlacementId[placementId]; const spawn = this.state.spawned[placementId]; if (prev && prev.nx === nx && prev.ny === ny) return this.state; const spawned = spawn ? { ...this.state.spawned, [placementId]: { ...spawn, nx, ny } } : this.state.spawned; this.state = { revision: this.state.revision + 1, byPlacementId: { ...this.state.byPlacementId, [placementId]: withMove(prev, nx, ny), }, scale: this.state.scale, spawned, }; return this.state; } case 'setDisposition': { const placementId = String(event.placementId ?? ''); if (!placementId) return this.state; const disposition = normalizeNpcDisposition(event.disposition); const prev = this.state.byPlacementId[placementId]; const spawn = this.state.spawned[placementId]; if (prev?.disposition === disposition && (!spawn || spawn.disposition === disposition)) { return this.state; } const next: SceneNpcTokensSessionPlacement = { ...restOf(prev), disposition, }; this.state = { revision: this.state.revision + 1, byPlacementId: { ...this.state.byPlacementId, [placementId]: next, }, scale: this.state.scale, spawned: spawn ? { ...this.state.spawned, [placementId]: { ...spawn, disposition } } : this.state.spawned, }; return this.state; } case 'setInactive': { const placementId = String(event.placementId ?? ''); if (!placementId) return this.state; const inactive = Boolean(event.inactive); const prev = this.state.byPlacementId[placementId]; if (Boolean(prev?.inactive) === inactive) return this.state; const next: SceneNpcTokensSessionPlacement = { ...(prev?.nx !== undefined ? { nx: prev.nx } : {}), ...(prev?.ny !== undefined ? { ny: prev.ny } : {}), ...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}), ...(prev?.disposition ? { disposition: prev.disposition } : {}), ...(inactive ? { inactive: true } : {}), }; if (isEmptyPlacement(next)) { this.state = withoutPlacement(this.state, placementId); return this.state; } this.state = { revision: this.state.revision + 1, byPlacementId: { ...this.state.byPlacementId, [placementId]: next, }, scale: this.state.scale, spawned: this.state.spawned, }; return this.state; } case 'setSize': { const placementId = String(event.placementId ?? ''); if (!placementId) return this.state; const sizeN = clampSceneNpcTokenSizeN(event.sizeN); const prev = this.state.byPlacementId[placementId]; const spawn = this.state.spawned[placementId]; if (prev?.sizeN === sizeN && (!spawn || spawn.sizeN === sizeN)) return this.state; this.state = { revision: this.state.revision + 1, byPlacementId: { ...this.state.byPlacementId, [placementId]: { ...restOf(prev), sizeN }, }, scale: this.state.scale, spawned: spawn ? { ...this.state.spawned, [placementId]: { ...spawn, sizeN } } : this.state.spawned, }; return this.state; } default: { const _exhaustive: never = event; void _exhaustive; return this.state; } } } }