diff --git a/packages/mcp/src/bridge/node-shims.ts b/packages/mcp/src/bridge/node-shims.ts new file mode 100644 index 00000000..e9ab9788 --- /dev/null +++ b/packages/mcp/src/bridge/node-shims.ts @@ -0,0 +1,33 @@ +/** + * Node-compatibility shims for `@pascal-app/core`. + * + * The core store uses `requestAnimationFrame` inside `updateNodesAction` (to batch + * dirty-marking) and inside the temporal undo/redo subscribe callback. Both are + * load-reachable — the subscribe callback registers at module import time. + * + * This file installs a no-op-if-already-defined polyfill that works both in + * Node and in the browser. It MUST be imported FIRST from any module that + * transitively loads `@pascal-app/core/store`, otherwise the core module will + * throw at import time. + * + * Side-effectful on import: there is no exported API — just import this file. + */ + +type RafCallback = (timestamp: number) => void + +type GlobalWithRaf = typeof globalThis & { + requestAnimationFrame?: (cb: RafCallback) => number + cancelAnimationFrame?: (id: number) => void +} + +const g = globalThis as GlobalWithRaf + +if (typeof g.requestAnimationFrame === 'undefined') { + g.requestAnimationFrame = (cb: RafCallback): number => { + const now = typeof performance !== 'undefined' ? performance.now() : Date.now() + return setTimeout(() => cb(now), 0) as unknown as number + } + g.cancelAnimationFrame = (id: number) => { + clearTimeout(id as unknown as ReturnType) + } +} diff --git a/packages/mcp/src/bridge/scene-bridge.test.ts b/packages/mcp/src/bridge/scene-bridge.test.ts new file mode 100644 index 00000000..cee3c220 --- /dev/null +++ b/packages/mcp/src/bridge/scene-bridge.test.ts @@ -0,0 +1,545 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + BuildingNode, + DoorNode, + ItemNode, + LevelNode, + SiteNode, + WallNode, + ZoneNode, +} from '@pascal-app/core/schema' +import { SceneBridge } from './scene-bridge' + +function tick() { + return new Promise((r) => setTimeout(r, 5)) +} + +describe('SceneBridge', () => { + let bridge: SceneBridge + + beforeEach(() => { + bridge = new SceneBridge() + // Ensure a clean slate even if a prior test left store state around + // (the core store is a module-singleton). + bridge.setScene({}, []) + bridge.clearHistory() + bridge.loadDefault() + bridge.clearHistory() + bridge.flushDirty() + }) + + describe('loadDefault / getters', () => { + test('creates default Site → Building → Level', () => { + const nodes = bridge.getNodes() + const types = Object.values(nodes) + .map((n) => n.type) + .sort() + expect(types).toEqual(['building', 'level', 'site']) + expect(bridge.getRootNodeIds().length).toBe(1) + }) + + test('loadDefault is idempotent when scene already loaded', () => { + const before = Object.keys(bridge.getNodes()).length + bridge.loadDefault() + const after = Object.keys(bridge.getNodes()).length + expect(after).toBe(before) + }) + + test('getNode returns the node by id', () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const fetched = bridge.getNode(level.id) + expect(fetched?.id).toBe(level.id) + }) + + test('getNode returns null for unknown id', () => { + expect(bridge.getNode('wall_does_not_exist')).toBeNull() + }) + }) + + describe('createNode', () => { + test('creates a wall attached to a level', () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const wall = WallNode.parse({ start: [0, 0], end: [5, 0] }) + const id = bridge.createNode(wall, level.id) + expect(id).toBe(wall.id) + expect(bridge.getNode(wall.id)).not.toBeNull() + // Level should list the wall as a child. + const freshLevel = bridge.getNode(level.id) as any + expect(freshLevel.children).toContain(wall.id) + }) + + test('created wall has the correct parentId', () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const wall = WallNode.parse({ start: [0, 0], end: [1, 0] }) + bridge.createNode(wall, level.id) + const w = bridge.getNode(wall.id)! + expect(w.parentId).toBe(level.id) + }) + }) + + describe('updateNode', () => { + test('merges new fields on existing node', async () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const wall = WallNode.parse({ start: [0, 0], end: [5, 0] }) + bridge.createNode(wall, level.id) + bridge.updateNode(wall.id, { thickness: 0.25, height: 3 } as any) + await tick() + const w = bridge.getNode(wall.id) as any + expect(w.thickness).toBe(0.25) + expect(w.height).toBe(3) + }) + + test('throws on unknown id', () => { + expect(() => bridge.updateNode('wall_missing' as any, { height: 3 } as any)).toThrow( + /node not found/, + ) + }) + }) + + describe('deleteNode', () => { + test('deletes a leaf node', () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const wall = WallNode.parse({ start: [0, 0], end: [1, 0] }) + bridge.createNode(wall, level.id) + const removed = bridge.deleteNode(wall.id) + expect(removed).toContain(wall.id) + expect(bridge.getNode(wall.id)).toBeNull() + }) + + test('cascade=false throws if node has children', () => { + // Level (with a child wall) — deleting non-cascaded must throw. + const level = bridge.findNodes({ type: 'level' })[0]! + const wall = WallNode.parse({ start: [0, 0], end: [1, 0] }) + bridge.createNode(wall, level.id) + expect(() => bridge.deleteNode(level.id, false)).toThrow(/descendant/) + // Node still exists. + expect(bridge.getNode(level.id)).not.toBeNull() + expect(bridge.getNode(wall.id)).not.toBeNull() + }) + + test('cascade=true removes node and all descendants', () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const wall1 = WallNode.parse({ start: [0, 0], end: [1, 0] }) + const wall2 = WallNode.parse({ start: [1, 0], end: [1, 1] }) + bridge.createNode(wall1, level.id) + bridge.createNode(wall2, level.id) + const removed = bridge.deleteNode(level.id, true) + expect(removed).toContain(level.id) + expect(removed).toContain(wall1.id) + expect(removed).toContain(wall2.id) + expect(bridge.getNode(level.id)).toBeNull() + expect(bridge.getNode(wall1.id)).toBeNull() + }) + + test('throws on unknown id', () => { + expect(() => bridge.deleteNode('wall_nope' as any, false)).toThrow(/node not found/) + }) + }) + + describe('undo / redo', () => { + test('round-trips create + update', async () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const wall = WallNode.parse({ start: [0, 0], end: [5, 0] }) + bridge.createNode(wall, level.id) + await tick() + bridge.updateNode(wall.id, { thickness: 0.25 } as any) + await tick() + + // Undo update + const u1 = bridge.undo() + await tick() + expect(u1).toBe(1) + const w1 = bridge.getNode(wall.id) as any + expect(w1).not.toBeNull() + expect(w1.thickness).not.toBe(0.25) + + // Undo create — wall should be gone + const u2 = bridge.undo() + await tick() + expect(u2).toBe(1) + expect(bridge.getNode(wall.id)).toBeNull() + + // Redo both + const r = bridge.redo(2) + await tick() + expect(r).toBe(2) + const w3 = bridge.getNode(wall.id) as any + expect(w3).not.toBeNull() + expect(w3.thickness).toBe(0.25) + }) + + test('getHistory tracks pointers', async () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const wall = WallNode.parse({ start: [0, 0], end: [1, 0] }) + bridge.createNode(wall, level.id) + await tick() + let h = bridge.getHistory() + expect(h.pastCount).toBe(1) + expect(h.futureCount).toBe(0) + bridge.undo() + await tick() + h = bridge.getHistory() + expect(h.pastCount).toBe(0) + expect(h.futureCount).toBe(1) + }) + + test('clearHistory wipes past/future', async () => { + const level = bridge.findNodes({ type: 'level' })[0]! + bridge.createNode(WallNode.parse({ start: [0, 0], end: [1, 0] }), level.id) + await tick() + bridge.clearHistory() + const h = bridge.getHistory() + expect(h.pastCount).toBe(0) + expect(h.futureCount).toBe(0) + }) + + test('undo/redo without history returns 0', () => { + bridge.clearHistory() + expect(bridge.undo()).toBe(0) + expect(bridge.redo()).toBe(0) + }) + }) + + describe('applyPatch', () => { + test('applies mixed create/update/delete atomically', async () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const wallA = WallNode.parse({ start: [0, 0], end: [2, 0] }) + const wallB = WallNode.parse({ start: [2, 0], end: [2, 2] }) + // pre-seed one wall, then exercise update + delete + bridge.createNode(wallA, level.id) + await tick() + + const res = bridge.applyPatch([ + { op: 'create', node: wallB, parentId: level.id }, + { op: 'update', id: wallA.id, data: { thickness: 0.3 } as any }, + { op: 'delete', id: wallA.id }, + ]) + await tick() + + expect(res.appliedOps).toBe(3) + expect(res.createdIds).toContain(wallB.id) + expect(res.deletedIds).toContain(wallA.id) + expect(bridge.getNode(wallA.id)).toBeNull() + expect(bridge.getNode(wallB.id)).not.toBeNull() + }) + + test('is all-or-nothing: invalid op rolls back no changes', async () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const pre = Object.keys(bridge.getNodes()).length + const wall = WallNode.parse({ start: [0, 0], end: [1, 0] }) + expect(() => + bridge.applyPatch([ + { op: 'create', node: wall, parentId: level.id }, + // This op is invalid — id does not exist. + { op: 'update', id: 'wall_missing' as any, data: { thickness: 0.1 } as any }, + ]), + ).toThrow(/invalid patch/) + // The wall must NOT have been created. + expect(bridge.getNode(wall.id)).toBeNull() + // Node count is unchanged. + expect(Object.keys(bridge.getNodes()).length).toBe(pre) + }) + + test('rejects create with non-existent parentId', () => { + const wall = WallNode.parse({ start: [0, 0], end: [1, 0] }) + expect(() => + bridge.applyPatch([{ op: 'create', node: wall, parentId: 'level_nope' as any }]), + ).toThrow(/invalid patch/) + }) + + test('rejects delete of unknown id', () => { + expect(() => bridge.applyPatch([{ op: 'delete', id: 'wall_nope' as any }])).toThrow( + /invalid patch/, + ) + }) + + test('rejects delete with cascade=false on a node with children', async () => { + const level = bridge.findNodes({ type: 'level' })[0]! + bridge.createNode(WallNode.parse({ start: [0, 0], end: [1, 0] }), level.id) + await tick() + expect(() => bridge.applyPatch([{ op: 'delete', id: level.id, cascade: false }])).toThrow( + /invalid patch/, + ) + }) + + test('accepts delete with cascade=true on a node with children', async () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const wall = WallNode.parse({ start: [0, 0], end: [1, 0] }) + bridge.createNode(wall, level.id) + await tick() + const res = bridge.applyPatch([{ op: 'delete', id: level.id, cascade: true }]) + expect(res.deletedIds).toContain(level.id) + expect(res.deletedIds).toContain(wall.id) + }) + + test('rejects create with schema-invalid node', () => { + // Bypass .parse so we can feed an invalid node through the union. + const bogus = { + object: 'node', + id: 'wall_bogus', + type: 'wall', + // missing start/end + } as any + expect(() => bridge.applyPatch([{ op: 'create', node: bogus }])).toThrow(/invalid patch/) + }) + + test('rejects unknown op', () => { + expect(() => bridge.applyPatch([{ op: 'wat', id: 'x' } as any])).toThrow(/invalid patch/) + }) + + test('rejects update with non-object data', () => { + const level = bridge.findNodes({ type: 'level' })[0]! + expect(() => bridge.applyPatch([{ op: 'update', id: level.id, data: null as any }])).toThrow( + /invalid patch/, + ) + }) + + test('rejects undefined patch entry', () => { + expect(() => bridge.applyPatch([undefined as any])).toThrow(/invalid patch/) + }) + }) + + describe('validateScene', () => { + test('returns valid for default scene', () => { + const res = bridge.validateScene() + expect(res.valid).toBe(true) + expect(res.errors).toEqual([]) + }) + + test('flags bad nodes fed in via setScene', () => { + const site = SiteNode.parse({}) + // Bypass the schema by constructing a bogus wall object directly. + const bogus = { + object: 'node', + id: 'wall_bogus', + type: 'wall', + parentId: site.id, + // missing required `start`/`end` + } as any + bridge.setScene({ [site.id]: site, [bogus.id]: bogus }, [site.id]) + const res = bridge.validateScene() + expect(res.valid).toBe(false) + expect(res.errors.some((e) => e.nodeId === 'wall_bogus')).toBe(true) + }) + }) + + describe('traversal: site quirk & generic helpers', () => { + test('getChildren uses the flat dict (handles site children-as-objects)', () => { + const site = bridge.findNodes({ type: 'site' })[0]! + const children = bridge.getChildren(site.id) + // Building is the expected child of site via parentId. + const types = children.map((c) => c.type).sort() + expect(types).toContain('building') + }) + + test('getChildren works for level (children-as-ids)', () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const wall = WallNode.parse({ start: [0, 0], end: [1, 0] }) + bridge.createNode(wall, level.id) + const children = bridge.getChildren(level.id) + expect(children.map((c) => c.id)).toContain(wall.id) + }) + + test('getAncestry walks to root', () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const wall = WallNode.parse({ start: [0, 0], end: [1, 0] }) + bridge.createNode(wall, level.id) + const ancestry = bridge.getAncestry(wall.id) + const types = ancestry.map((n) => n.type) + expect(types[0]).toBe('wall') + expect(types).toContain('level') + expect(types).toContain('building') + expect(types).toContain('site') + }) + + test('getAncestry returns [] for unknown id', () => { + expect(bridge.getAncestry('wall_nope' as any)).toEqual([]) + }) + + test('resolveLevelId returns the enclosing level', () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const wall = WallNode.parse({ start: [0, 0], end: [1, 0] }) + bridge.createNode(wall, level.id) + expect(bridge.resolveLevelId(wall.id)).toBe(level.id) + }) + + test('resolveLevelId returns null if no level ancestor', () => { + // Site itself has no level ancestor. + const site = bridge.findNodes({ type: 'site' })[0]! + expect(bridge.resolveLevelId(site.id)).toBeNull() + }) + + test('findNodes filters by type', () => { + const levels = bridge.findNodes({ type: 'level' }) + expect(levels.length).toBe(1) + expect(levels[0]?.type).toBe('level') + }) + + test('findNodes filters by parentId', () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const wall = WallNode.parse({ start: [0, 0], end: [1, 0] }) + bridge.createNode(wall, level.id) + const childrenOfLevel = bridge.findNodes({ parentId: level.id }) + expect(childrenOfLevel.map((n) => n.id)).toContain(wall.id) + }) + + test('findNodes filters by levelId (via ancestry)', () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const wall = WallNode.parse({ start: [0, 0], end: [1, 0] }) + bridge.createNode(wall, level.id) + const door = DoorNode.parse({ wallId: wall.id }) + bridge.createNode(door, wall.id) + // Door lives under wall→level; findNodes with levelId should match. + const filtered = bridge.findNodes({ type: 'door', levelId: level.id }) + expect(filtered.map((n) => n.id)).toContain(door.id) + }) + + test('findNodes with parentId: null finds roots', () => { + const roots = bridge.findNodes({ parentId: null }) + expect(roots.map((n) => n.type)).toContain('site') + }) + }) + + describe('setScene / exportJSON / loadJSON', () => { + test('exportJSON returns the scene shape', () => { + const exp = bridge.exportJSON() + expect(typeof exp.nodes).toBe('object') + expect(Array.isArray(exp.rootNodeIds)).toBe(true) + expect(exp.rootNodeIds.length).toBe(1) + }) + + test('exportJSON deep-clones (mutation does not leak back)', () => { + const exp = bridge.exportJSON() + const someId = Object.keys(exp.nodes)[0]! + ;(exp.nodes as any)[someId] = 'tampered' + // Store is unchanged. + expect(typeof bridge.getNodes()[someId]).toBe('object') + }) + + test('loadJSON accepts a parsed object', () => { + const snap = bridge.exportJSON() + // Unload first so loadJSON does the heavy lift. + bridge.setScene({}, []) + bridge.loadJSON(snap) + expect(Object.keys(bridge.getNodes()).length).toBe(Object.keys(snap.nodes).length) + }) + + test('loadJSON accepts a JSON string', () => { + const snap = bridge.exportJSON() + const str = JSON.stringify(snap) + bridge.setScene({}, []) + bridge.loadJSON(str) + expect(Object.keys(bridge.getNodes()).length).toBe(Object.keys(snap.nodes).length) + }) + + test('loadJSON throws on malformed JSON string', () => { + expect(() => bridge.loadJSON('not json')).toThrow(/invalid JSON/) + }) + + test('loadJSON throws when parsed JSON is not an object', () => { + expect(() => bridge.loadJSON('null')).toThrow(/expected object/) + expect(() => bridge.loadJSON(null as any)).toThrow(/expected object/) + }) + + test('loadJSON throws on wrong top-level shape', () => { + expect(() => bridge.loadJSON({} as any)).toThrow(/invalid scene/) + expect(() => bridge.loadJSON({ nodes: 1, rootNodeIds: [] } as any)).toThrow(/invalid scene/) + expect(() => bridge.loadJSON({ nodes: {}, rootNodeIds: 'nope' } as any)).toThrow( + /invalid scene/, + ) + }) + + test('loadJSON rejects prototype-polluting keys in string form', () => { + const bad = '{"nodes": {"__proto__": {"polluted": true}}, "rootNodeIds": []}' + expect(() => bridge.loadJSON(bad)).toThrow(/forbidden key/) + }) + + test('loadJSON rejects prototype-polluting keys in object form', () => { + // Build object so the key is an actual own-property (not a prototype + // assignment). + const nodes: Record = {} + Object.defineProperty(nodes, '__proto__', { + enumerable: true, + configurable: true, + writable: true, + value: { polluted: true }, + }) + const bad = { nodes, rootNodeIds: [] } + expect(() => bridge.loadJSON(bad as any)).toThrow(/forbidden key/) + }) + + test('setScene round-trip preserves node count', () => { + const pre = Object.keys(bridge.getNodes()).length + const snap = bridge.exportJSON() + bridge.setScene({}, []) + bridge.setScene(snap.nodes as any, snap.rootNodeIds as any) + expect(Object.keys(bridge.getNodes()).length).toBe(pre) + }) + }) + + describe('flushDirty', () => { + test('drains the dirty set', async () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const wall = WallNode.parse({ start: [0, 0], end: [1, 0] }) + bridge.createNode(wall, level.id) + await tick() + const drained = bridge.flushDirty() + // Wall was just created, should have dirty-marked itself + parent. + expect(drained.length).toBeGreaterThan(0) + // Calling again drains nothing new. + const again = bridge.flushDirty() + expect(again.length).toBe(0) + }) + }) + + describe('composite nodes', () => { + test('can build a small scene via LevelNode/BuildingNode helpers', () => { + // Construct a second site via explicit schema parse to exercise + // exportJSON/setScene on custom shapes. + const level = LevelNode.parse({ level: 0, children: [] }) + const building = BuildingNode.parse({ children: [level.id] }) + const site = SiteNode.parse({ children: [] }) + bridge.setScene( + { + [site.id]: { ...site, children: [] } as any, + [building.id]: { ...building, parentId: site.id } as any, + [level.id]: { ...level, parentId: building.id } as any, + }, + [site.id], + ) + expect(bridge.getNodes()[site.id]).toBeDefined() + expect(bridge.resolveLevelId(level.id)).toBe(level.id) + }) + + test('zone and item nodes are creatable and discoverable', () => { + const level = bridge.findNodes({ type: 'level' })[0]! + const zone = ZoneNode.parse({ + name: 'Zone A', + polygon: [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ], + }) + bridge.createNode(zone, level.id) + + const item = ItemNode.parse({ + asset: { + id: 'asset_test', + category: 'test', + name: 'Test Asset', + thumbnail: 'data:image/png;base64,', + src: 'data:model/gltf-binary;base64,', + }, + }) + // Place item directly on level — ItemNode supports arbitrary parents in the model. + bridge.createNode(item, level.id) + + const zones = bridge.findNodes({ type: 'zone' }) + const items = bridge.findNodes({ type: 'item' }) + expect(zones.map((n) => n.id)).toContain(zone.id) + expect(items.map((n) => n.id)).toContain(item.id) + }) + }) +}) diff --git a/packages/mcp/src/bridge/scene-bridge.ts b/packages/mcp/src/bridge/scene-bridge.ts new file mode 100644 index 00000000..1f132ea1 --- /dev/null +++ b/packages/mcp/src/bridge/scene-bridge.ts @@ -0,0 +1,500 @@ +// Side-effect import MUST come first: installs RAF polyfill before core loads. +import './node-shims' + +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import type { AnyNode } from '@pascal-app/core/schema' +import { type AnyNodeId, AnyNode as AnyNodeSchema, type AnyNodeType } from '@pascal-app/core/schema' +// Per PLAN §0.6: `useScene` is the DEFAULT export from `@pascal-app/core/store`. +import useScene from '@pascal-app/core/store' + +export type ValidationError = { nodeId: string; path: string; message: string } +export type ValidationResult = { valid: boolean; errors: ValidationError[] } + +export type CreatePatch = { op: 'create'; node: AnyNode; parentId?: AnyNodeId } +export type UpdatePatch = { op: 'update'; id: AnyNodeId; data: Partial } +export type DeletePatch = { op: 'delete'; id: AnyNodeId; cascade?: boolean } +export type Patch = CreatePatch | UpdatePatch | DeletePatch + +/** + * Headless bridge to the `@pascal-app/core` Zustand store. + * + * All mutation flows through the real core store so undo/redo works via Zundo. + * No renderer is attached; `dirtyNodes` accumulates and can be drained via + * `flushDirty()` for observability. + */ +export class SceneBridge { + /** Load initial state; if empty, creates default Site → Building → Level. */ + loadDefault(): void { + useScene.getState().loadScene() + } + + /** Replace entire scene (undoable via Zundo). */ + setScene(nodes: Record, rootNodeIds: AnyNodeId[]): void { + useScene.getState().setScene(nodes, rootNodeIds) + } + + /** Full snapshot for export, including collections. */ + exportJSON(): SceneGraph & { collections: Record } { + const state = useScene.getState() + // Deep-clone so callers can't mutate store state directly. + return JSON.parse( + JSON.stringify({ + nodes: state.nodes, + rootNodeIds: state.rootNodeIds, + collections: state.collections ?? {}, + }), + ) + } + + /** + * Import. Accepts either a JSON string or a parsed SceneGraph object. + * Throws on invalid JSON, unexpected shape, or prototype-polluting keys. + */ + loadJSON(json: string | SceneGraph): void { + let parsed: unknown + if (typeof json === 'string') { + try { + parsed = JSON.parse(json) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + throw new Error(`invalid JSON: ${msg}`) + } + } else { + parsed = json + } + + if (!parsed || typeof parsed !== 'object') { + throw new Error('invalid scene: expected object with {nodes, rootNodeIds}') + } + + const obj = parsed as Record + const nodes = obj.nodes + const rootNodeIds = obj.rootNodeIds + + if (!nodes || typeof nodes !== 'object' || Array.isArray(nodes)) { + throw new Error('invalid scene: `nodes` must be an object') + } + if (!Array.isArray(rootNodeIds)) { + throw new Error('invalid scene: `rootNodeIds` must be an array') + } + + // Reject prototype-polluting keys as top-level `nodes` keys. + const BANNED = new Set(['__proto__', 'constructor', 'prototype']) + for (const key of Object.keys(nodes)) { + if (BANNED.has(key)) { + throw new Error(`invalid scene: forbidden key "${key}" in nodes`) + } + } + + this.setScene(nodes as Record, rootNodeIds as AnyNodeId[]) + } + + /** Read a single node, or `null` if not present. */ + getNode(id: AnyNodeId): AnyNode | null { + const node = useScene.getState().nodes[id] + return node ?? null + } + + /** All nodes (live reference into the store — do NOT mutate). */ + getNodes(): Record { + return useScene.getState().nodes + } + + /** Root node IDs. */ + getRootNodeIds(): AnyNodeId[] { + return useScene.getState().rootNodeIds + } + + /** + * Resolve children via the flat `nodes` dict. Uses THREE fallbacks because + * the codebase's parent-tracking is not uniform: + * + * 1. `node.parentId === parentId` (normal case post-store-mutation). + * 2. Parent has `children: string[]` of IDs (building, level, wall, ...). + * 3. Parent has `children: Array` (the SiteNode quirk — see + * PLAN §0.7). We resolve each object to its flat-dict entry by `id`. + * + * The `loadScene()` default assembler skips the store mutation paths so the + * default site/building/level tree has `parentId === null` on every node — + * only the `children` arrays reflect the hierarchy. + * + * Results are de-duplicated by id, in flat-dict iteration order. + */ + getChildren(parentId: AnyNodeId): AnyNode[] { + const nodes = useScene.getState().nodes + const out: AnyNode[] = [] + const seen = new Set() + + // Strategy 1: parentId scan. + for (const node of Object.values(nodes)) { + if (node.parentId === parentId && !seen.has(node.id as AnyNodeId)) { + seen.add(node.id as AnyNodeId) + out.push(node) + } + } + + // Strategies 2 & 3: parent's own `children` field. + const parent = nodes[parentId] + if (parent && 'children' in parent && Array.isArray(parent.children)) { + for (const child of parent.children as unknown[]) { + let childId: string | null = null + if (typeof child === 'string') childId = child + else if ( + child && + typeof child === 'object' && + 'id' in (child as Record) && + typeof (child as { id: unknown }).id === 'string' + ) { + childId = (child as { id: string }).id + } + if (!childId) continue + const childNode = nodes[childId as AnyNodeId] + if (!childNode) continue + if (seen.has(childNode.id as AnyNodeId)) continue + seen.add(childNode.id as AnyNodeId) + out.push(childNode) + } + } + + return out + } + + /** + * Walk up `parentId` chain; returns `[self, parent, grandparent, ...]`. + * + * Falls back to reverse-scanning `children` arrays when `parentId` is + * unset (see the default-scene quirk documented on `getChildren`). + */ + getAncestry(id: AnyNodeId): AnyNode[] { + const nodes = useScene.getState().nodes + const out: AnyNode[] = [] + let current: AnyNode | undefined = nodes[id] + const seen = new Set() + while (current && !seen.has(current.id)) { + seen.add(current.id) + out.push(current) + const pid = current.parentId as AnyNodeId | null | undefined + if (pid && nodes[pid]) { + current = nodes[pid] + continue + } + // Fallback: scan for any node whose `children` includes this id. + const fallback = this._findParentByChildrenScan(current.id as AnyNodeId) + if (!fallback) break + current = fallback + } + return out + } + + /** Find all nodes matching the given filters (all filters ANDed). */ + findNodes(filter: { + type?: AnyNodeType + parentId?: AnyNodeId | null + levelId?: AnyNodeId + }): AnyNode[] { + const nodes = useScene.getState().nodes + const out: AnyNode[] = [] + for (const node of Object.values(nodes)) { + if (filter.type !== undefined && node.type !== filter.type) continue + if (filter.parentId !== undefined) { + const np = (node.parentId ?? null) as AnyNodeId | null + if (np !== filter.parentId) continue + } + if (filter.levelId !== undefined) { + if (this.resolveLevelId(node.id as AnyNodeId) !== filter.levelId) continue + } + out.push(node) + } + return out + } + + /** Resolve the level-ancestor of a node, or `null` if none in the chain. */ + resolveLevelId(id: AnyNodeId): AnyNodeId | null { + const ancestry = this.getAncestry(id) + for (const node of ancestry) { + if (node.type === 'level') return node.id as AnyNodeId + } + return null + } + + /** + * Create a node. Caller must pass an already-parsed `AnyNode` (with a valid + * `id`, generated by the schema default if they did `XxxNode.parse({...})`). + * Returns the generated id. + */ + createNode(node: AnyNode, parentId?: AnyNodeId): AnyNodeId { + useScene.getState().createNode(node, parentId) + return node.id as AnyNodeId + } + + /** Update node fields (shallow merge through the core store). */ + updateNode(id: AnyNodeId, data: Partial): void { + if (!useScene.getState().nodes[id]) { + throw new Error(`node not found: ${id}`) + } + useScene.getState().updateNode(id, data) + } + + /** + * Delete a node. If the node has children and `cascade === false`, throws. + * If `cascade` is true (or undefined and no children), delegates to the core + * action which already recursively removes descendants. + * + * Returns the list of ids actually removed from the scene. + */ + deleteNode(id: AnyNodeId, cascade = false): string[] { + const state = useScene.getState() + const node = state.nodes[id] + if (!node) { + throw new Error(`node not found: ${id}`) + } + + const descendants = this._collectDescendants(id) + if (!cascade && descendants.length > 1) { + throw new Error( + `node has ${descendants.length - 1} descendant(s); pass cascade: true to delete recursively`, + ) + } + + const before = new Set(Object.keys(state.nodes)) + useScene.getState().deleteNode(id) + const afterNodes = useScene.getState().nodes + const removed: string[] = [] + for (const prevId of before) { + if (!(prevId in afterNodes)) removed.push(prevId) + } + return removed + } + + /** + * Atomic multi-op patch. Validates EVERY patch first (dry run); only if all + * pass does it apply in a single batch via `createNodes` / `updateNodes` / + * `deleteNodes`. Throws on any validation failure without mutating state. + */ + applyPatch(patches: Patch[]): { + appliedOps: number + deletedIds: AnyNodeId[] + createdIds: AnyNodeId[] + } { + const state = useScene.getState() + const nodes = state.nodes + + // Track synthesized state as we dry-run so later ops can reference + // earlier-created ids and reflect earlier-deleted ids. + const simAvailable = new Set(Object.keys(nodes)) + const simDeleted = new Set() + + for (let i = 0; i < patches.length; i++) { + const p = patches[i] + if (!p) throw new Error(`invalid patch: patches[${i}] is undefined`) + if (p.op === 'create') { + const res = AnyNodeSchema.safeParse(p.node) + if (!res.success) { + throw new Error( + `invalid patch: patches[${i}] create node failed schema: ${res.error.message}`, + ) + } + if (p.parentId !== undefined && !simAvailable.has(p.parentId)) { + throw new Error(`invalid patch: patches[${i}] create parentId "${p.parentId}" not found`) + } + simAvailable.add(p.node.id) + } else if (p.op === 'update') { + if (!simAvailable.has(p.id) || simDeleted.has(p.id)) { + throw new Error(`invalid patch: patches[${i}] update id "${p.id}" not found`) + } + if (!p.data || typeof p.data !== 'object') { + throw new Error(`invalid patch: patches[${i}] update data is not an object`) + } + } else if (p.op === 'delete') { + if (!simAvailable.has(p.id) || simDeleted.has(p.id)) { + throw new Error(`invalid patch: patches[${i}] delete id "${p.id}" not found`) + } + if (p.cascade === false) { + // Only inspect the current store state — we don't simulate + // descendant additions during dry-run, because that would require + // building a full shadow tree. This matches the semantics of the + // single-op deleteNode guard. + const desc = this._collectDescendants(p.id) + if (desc.length > 1) { + throw new Error( + `invalid patch: patches[${i}] delete "${p.id}" has descendants; pass cascade: true`, + ) + } + } + simAvailable.delete(p.id) + simDeleted.add(p.id) + } else { + throw new Error(`invalid patch: patches[${i}] unknown op`) + } + } + + // Dry-run succeeded — apply in order, batching adjacent ops of the same + // op type so Zundo groups them tightly. + const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [] + const updateOps: { id: AnyNodeId; data: Partial }[] = [] + const deleteIds: AnyNodeId[] = [] + const createdIds: AnyNodeId[] = [] + + // Simple approach: queue by type, flush in original order by walking + // patches and interleaving flushes when the op type changes, so ids + // created/updated/deleted stay temporally consistent. + const flush = (kind: 'create' | 'update' | 'delete' | 'none') => { + if (kind !== 'create' && createOps.length > 0) { + useScene.getState().createNodes(createOps) + createOps.length = 0 + } + if (kind !== 'update' && updateOps.length > 0) { + useScene.getState().updateNodes(updateOps) + updateOps.length = 0 + } + if (kind !== 'delete' && deleteIds.length > 0) { + useScene.getState().deleteNodes(deleteIds) + deleteIds.length = 0 + } + } + + for (const p of patches) { + if (p.op === 'create') { + flush('create') + createOps.push({ node: p.node, parentId: p.parentId }) + createdIds.push(p.node.id as AnyNodeId) + } else if (p.op === 'update') { + flush('update') + updateOps.push({ id: p.id, data: p.data }) + } else { + flush('delete') + deleteIds.push(p.id) + } + } + flush('none') + + // Compute actual deleted ids by diffing pre/post snapshots. + const postNodes = useScene.getState().nodes + const deletedIds: AnyNodeId[] = [] + for (const prevId of Object.keys(nodes)) { + if (!(prevId in postNodes)) deletedIds.push(prevId as AnyNodeId) + } + + return { + appliedOps: patches.length, + deletedIds, + createdIds, + } + } + + /** Undo. Returns the number of steps actually undone. */ + undo(steps = 1): number { + const before = useScene.temporal.getState().pastStates.length + useScene.temporal.getState().undo(steps) + const after = useScene.temporal.getState().pastStates.length + return Math.max(0, before - after) + } + + /** Redo. Returns the number of steps actually redone. */ + redo(steps = 1): number { + const before = useScene.temporal.getState().futureStates.length + useScene.temporal.getState().redo(steps) + const after = useScene.temporal.getState().futureStates.length + return Math.max(0, before - after) + } + + /** + * Zod-validate every node in the scene. Reports one error per failed node, + * concatenating Zod issue paths. + */ + validateScene(): ValidationResult { + const errors: ValidationError[] = [] + const nodes = useScene.getState().nodes + for (const [id, node] of Object.entries(nodes)) { + const res = AnyNodeSchema.safeParse(node) + if (res.success) continue + for (const issue of res.error.issues) { + errors.push({ + nodeId: id, + path: issue.path.join('.'), + message: issue.message, + }) + } + } + return { valid: errors.length === 0, errors } + } + + /** + * Drain the dirtyNodes set. Returns the ids that were present. No-op for + * renderer (there is no renderer in MCP mode); useful for observability. + */ + flushDirty(): string[] { + const state = useScene.getState() + const ids = Array.from(state.dirtyNodes) + for (const id of ids) { + state.clearDirty(id as AnyNodeId) + } + return ids + } + + /** Current temporal history pointers. */ + getHistory(): { pastCount: number; futureCount: number } { + const t = useScene.temporal.getState() + return { + pastCount: t.pastStates.length, + futureCount: t.futureStates.length, + } + } + + /** Clear the temporal undo/redo history. */ + clearHistory(): void { + useScene.temporal.getState().clear() + } + + // ---- internal helpers ---- + + /** + * Return the node whose `children` array (string or object form) contains + * the given id, or null if none. Used as a fallback when `parentId` is + * missing on a node. + */ + private _findParentByChildrenScan(id: AnyNodeId): AnyNode | null { + const nodes = useScene.getState().nodes + for (const candidate of Object.values(nodes)) { + if (!('children' in candidate) || !Array.isArray(candidate.children)) continue + for (const child of candidate.children as unknown[]) { + let childId: string | null = null + if (typeof child === 'string') childId = child + else if ( + child && + typeof child === 'object' && + 'id' in (child as Record) && + typeof (child as { id: unknown }).id === 'string' + ) { + childId = (child as { id: string }).id + } + if (childId === id) return candidate + } + } + return null + } + + /** + * Collect ids of a node and all its descendants. Uses the same combined + * strategy as `getChildren` (parentId scan + children-array walk) so that + * the SiteNode quirk and the default-scene parentId-unset case both work. + */ + private _collectDescendants(id: AnyNodeId): AnyNodeId[] { + const nodes = useScene.getState().nodes + if (!nodes[id]) return [] + const out: AnyNodeId[] = [] + const stack: AnyNodeId[] = [id] + const seen = new Set() + // Precompute parent → child[] index from parentId only. `children` arrays + // are consulted on-the-fly via getChildren. + while (stack.length > 0) { + const curr = stack.pop()! + if (seen.has(curr)) continue + seen.add(curr) + out.push(curr) + const children = this.getChildren(curr) + for (const c of children) stack.push(c.id as AnyNodeId) + } + return out + } +}